pwn 0.5.759 → 0.5.761
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile +1 -1
- data/documentation/pwn-ai-Agent.md +44 -10
- data/etc/default_skills/pwn/banner/SKILL.md +1 -1
- data/lib/pwn/ai/agent/loop.rb +73 -18
- data/lib/pwn/banner.rb +740 -81
- data/lib/pwn/plugins/repl/ai_console.rb +97 -10
- data/lib/pwn/plugins/repl/ai_swarm.rb +1 -0
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/loop_spec.rb +1 -1
- data/spec/lib/pwn/banner_spec.rb +523 -19
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +267 -5
- data/third_party/pwn_rdoc.jsonl +36 -3
- metadata +3 -3
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|
|
|
@@ -25,7 +271,250 @@ describe PWN::Banner do
|
|
|
25
271
|
end
|
|
26
272
|
end
|
|
27
273
|
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
describe PWN::Banner::MiniGame do
|
|
277
|
+
it 'paints Galaga as a filled symmetric upward triangle without changing ship physics' do
|
|
278
|
+
[8, 12, 24].each do |size|
|
|
279
|
+
game = described_class.new(name: :galaga, width: size, height: size)
|
|
280
|
+
frame = game.step
|
|
281
|
+
expect(frame[:state][:ship]).to eq([size / 2.0, size - 1.0])
|
|
282
|
+
dots = []
|
|
283
|
+
game.cells(frame).each_with_index do |row, y|
|
|
284
|
+
row.each_with_index do |cell, x|
|
|
285
|
+
next unless cell[:foreground] == :cyan
|
|
286
|
+
|
|
287
|
+
mask = cell[:glyph].ord - 0x2800
|
|
288
|
+
[[0, 0], [0, 1], [0, 2], [1, 0], [1, 1], [1, 2], [0, 3], [1, 3]].each_with_index do |(dx, dy), bit|
|
|
289
|
+
dots << [(x * 2) + dx, (y * 4) + dy] if mask[bit] == 1
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
rows = dots.group_by(&:last).sort.map { |_, points| points.map(&:first).sort }
|
|
294
|
+
expect(rows.map(&:length)).to eq([1, 3, 3, 5, 5])
|
|
295
|
+
rows.each { |xs| expect(xs).to eq((xs.first..xs.last).to_a) }
|
|
296
|
+
expect(rows.map { |xs| xs.first + xs.last }.uniq.length).to eq(1)
|
|
297
|
+
game.key(:left)
|
|
298
|
+
expect(game.step[:state][:ship]).to eq([(size / 2.0) - 0.75, size - 1.0])
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
it 'retains inner walls and branching corridors with single-cell actors in delivered console panes' do
|
|
303
|
+
# The console starts at an eight-cell interior at both 100x26 and 120x36.
|
|
304
|
+
# Settings can grow that pane; include odd, even and large interiors.
|
|
305
|
+
[8, 9, 12, 18, 24].each do |size|
|
|
306
|
+
layouts = 12.times.map do |seed|
|
|
307
|
+
game = described_class.new(name: :pacman, width: size, height: size, seed: seed)
|
|
308
|
+
frame = game.step
|
|
309
|
+
maze = frame[:state][:maze]
|
|
310
|
+
expect(maze.length).to eq(size)
|
|
311
|
+
expect(maze.map(&:length)).to all(eq(size))
|
|
312
|
+
inner = maze[2...-2].map { |row| row[2...-2] }.join
|
|
313
|
+
expect(inner.count('#')).to be >= 4
|
|
314
|
+
expect(inner.count('#').fdiv(inner.length)).to be_between(0.2, 0.8)
|
|
315
|
+
expect(inner.count(' ')).to be >= 4
|
|
316
|
+
reached = [frame[:state][:pacman]]
|
|
317
|
+
reached.each do |point|
|
|
318
|
+
PWN::Banner.send(:mini_pacman_neighbors, point: point, maze: maze).each_value do |neighbor|
|
|
319
|
+
reached << neighbor unless reached.include?(neighbor)
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
expect(reached.length).to eq(maze.join.count(' '))
|
|
323
|
+
expect(frame[:rows].join.count('><^vO')).to eq(1)
|
|
324
|
+
expect(frame[:rows].join.count('RMCF')).to eq(frame[:state][:ghosts].length)
|
|
325
|
+
expect(frame[:rows][1]).to eq('#' * size)
|
|
326
|
+
expect(frame[:rows].last).to eq('#' * size)
|
|
327
|
+
game.key(:right)
|
|
328
|
+
expect(game.step[:state][:pacman]).to eq([2, size - 2])
|
|
329
|
+
maze
|
|
330
|
+
end
|
|
331
|
+
expect(layouts.uniq.length).to be >= 8
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
it 'uses the entire Pac-Man pane beyond sixteen cells without shrinking or padding the maze' do
|
|
336
|
+
[17, 24, 64].each do |size|
|
|
337
|
+
game = described_class.new(name: :pacman, width: size, height: size)
|
|
338
|
+
frame = game.step
|
|
339
|
+
expect(game.cells(frame).length).to eq(size)
|
|
340
|
+
expect(game.cells(frame).map(&:length)).to all(eq(size))
|
|
341
|
+
expect(frame[:state][:maze].last).to eq('#' * size)
|
|
342
|
+
game.key(:up)
|
|
343
|
+
expect(game.step[:state][:pacman]).to eq([1, size - 3])
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
it 'starts Pac-Man on arrows, eats pellets, buffers a blocked turn and stops at walls' do
|
|
348
|
+
game = described_class.new(name: :pacman, width: 7, height: 7, seed: 9)
|
|
349
|
+
expect(game.step[:state][:pacman]).to eq([1, 5])
|
|
350
|
+
game.key(:right)
|
|
351
|
+
moved = game.step
|
|
352
|
+
expect(moved[:state][:pacman]).to eq([2, 5])
|
|
353
|
+
expect(moved[:state][:score]).to eq(10)
|
|
354
|
+
game.key(:up)
|
|
355
|
+
2.times { game.step }
|
|
356
|
+
expect(game.step[:state][:pacman]).to eq([3, 5])
|
|
357
|
+
2.times { game.step }
|
|
358
|
+
expect(game.step[:state][:pacman]).to eq([3, 4])
|
|
359
|
+
game.key(:left)
|
|
360
|
+
2.times { game.step }
|
|
361
|
+
expect(game.step[:state][:pacman]).to eq([3, 3])
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
it 'renders bounded, unbranded colored frames without accumulating a replay' do
|
|
365
|
+
PWN::Banner.mini_names.product([5, 8, 16]).each do |name, size|
|
|
366
|
+
game = described_class.new(name: name, width: size, height: size)
|
|
367
|
+
40.times do
|
|
368
|
+
frame = game.step
|
|
369
|
+
cells = game.cells(frame)
|
|
370
|
+
expect(cells.length).to eq(size)
|
|
371
|
+
expect(cells.map(&:length)).to all(eq(size))
|
|
372
|
+
expect(cells.flatten.map { |cell| cell[:glyph] }.join).not_to match(/[PWN]/)
|
|
373
|
+
expect(cells.flatten).to all(include(:foreground, :background))
|
|
374
|
+
end
|
|
375
|
+
expect(game.length).to eq(0)
|
|
376
|
+
end
|
|
377
|
+
end
|
|
28
378
|
|
|
379
|
+
it 'lets the player move only the left Pong paddle while the ball and opponent keep playing' do
|
|
380
|
+
game = PWN::Banner::MiniGame.new(name: :pong, width: 8, height: 8, seed: 73)
|
|
381
|
+
first = game.step
|
|
382
|
+
game.key(:up)
|
|
383
|
+
moved = game.step
|
|
384
|
+
expect(moved[:state][:paddles][0]).to be < first[:state][:paddles][0]
|
|
385
|
+
expect(moved[:state][:ball]).not_to eq(first[:state][:ball])
|
|
386
|
+
game.key(:down)
|
|
387
|
+
expect(game.step[:state][:paddles][0]).to eq(first[:state][:paddles][0])
|
|
388
|
+
game.key(:left)
|
|
389
|
+
expect(game.step[:state][:paddles][0]).to eq(first[:state][:paddles][0])
|
|
390
|
+
100.times do
|
|
391
|
+
game.key(:up)
|
|
392
|
+
game.step
|
|
393
|
+
end
|
|
394
|
+
expect(game.step[:state][:paddles][0]).to eq(0)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
it 'steers Snake with arrows without reversing into its neck or changing direction twice per tick' do
|
|
398
|
+
game = PWN::Banner::MiniGame.new(name: :snake, width: 16, height: 16, seed: 73)
|
|
399
|
+
first = game.step[:state]
|
|
400
|
+
game.key(:left)
|
|
401
|
+
moved = game.step[:state]
|
|
402
|
+
expect(moved[:body].first).to eq([first[:body][0][0] + 1, first[:body][0][1]])
|
|
403
|
+
turn = moved[:body][0][1].positive? ? :up : :down
|
|
404
|
+
game.key(turn)
|
|
405
|
+
game.key(:left)
|
|
406
|
+
turned = game.step[:state]
|
|
407
|
+
expect(turned[:body].first).to eq([moved[:body][0][0], moved[:body][0][1] + (turn == :up ? -1 : 1)])
|
|
408
|
+
game.key(:left)
|
|
409
|
+
expect(game.step[:state][:body].first[0]).to eq(turned[:body][0][0] - 1)
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
it 'keeps Snake stopped after collision until a new arrow starts the next round' do
|
|
413
|
+
game = described_class.new(name: :snake, width: 5, height: 5, seed: 73)
|
|
414
|
+
expect(game.step[:event]).to eq(:ready)
|
|
415
|
+
game.key(:right)
|
|
416
|
+
7.times { expect(game.step[:event]).to eq(:play) }
|
|
417
|
+
expect(game.step[:event]).to eq(:game_over)
|
|
418
|
+
ready = game.step
|
|
419
|
+
expect(ready[:event]).to eq(:ready)
|
|
420
|
+
20.times { expect(game.step).to eq(ready) }
|
|
421
|
+
game.key(:up)
|
|
422
|
+
expect(game.step[:state][:body].first).to eq([2, 4])
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
it 'packs playable Snake over the full width and bottom with quarter-cell pixels' do
|
|
426
|
+
[5, 8, 16].each do |size|
|
|
427
|
+
board = Array.new(size * 2) { ' ' * (size * 2) }
|
|
428
|
+
board[0][0] = 'g'
|
|
429
|
+
board[-1][-1] = 'y'
|
|
430
|
+
game = described_class.new(name: :snake, width: size, height: size)
|
|
431
|
+
cells = game.cells(rows: ['label'] + board)
|
|
432
|
+
expect(cells.first.first).to include(glyph: '▘', foreground: :green)
|
|
433
|
+
expect(cells.last.last).to include(glyph: '▗', foreground: :yellow)
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
it 'moves Tetris sideways, rotates with space and soft drops one row with down' do
|
|
438
|
+
game = PWN::Banner::MiniGame.new(name: :falling_blocks, width: 8, height: 8, seed: 73)
|
|
439
|
+
first = game.step[:state]
|
|
440
|
+
game.key(:left)
|
|
441
|
+
left = game.step[:state]
|
|
442
|
+
expect(left[:active]).to eq(first[:active].map { |x, y| [x - 1, y] })
|
|
443
|
+
game.key(:right)
|
|
444
|
+
expect(game.step[:state][:active]).to eq(first[:active])
|
|
445
|
+
game.key(' ')
|
|
446
|
+
rotated = game.step[:state]
|
|
447
|
+
expect(rotated[:active]).not_to eq(first[:active])
|
|
448
|
+
expect(rotated[:active].map(&:last).min).to eq(0)
|
|
449
|
+
game.key(:down)
|
|
450
|
+
dropped = game.step[:state]
|
|
451
|
+
expect(dropped[:active]).to eq(rotated[:active].map { |x, y| [x, y + 1] })
|
|
452
|
+
expect(dropped[:stack]).to eq(first[:stack])
|
|
453
|
+
events = 150.times.map do
|
|
454
|
+
game.key(:down)
|
|
455
|
+
game.step[:event]
|
|
456
|
+
end
|
|
457
|
+
expect(events).to include(:lock)
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
it 'explodes on a real ship impact, ignores wreck controls, then recovers with brief protection' do
|
|
461
|
+
rng = instance_double(Random)
|
|
462
|
+
allow(Random).to receive(:new).with(73).and_return(rng)
|
|
463
|
+
allow(rng).to receive(:rand).and_return(0.0, 0.82, 0.5, 0.0, 0.5)
|
|
464
|
+
game = described_class.new(name: :asteroids, width: 5, height: 5, seed: 73)
|
|
465
|
+
frames = Array.new(20) { game.step }
|
|
466
|
+
crash = frames.find { |frame| frame[:event] == :crash }
|
|
467
|
+
expect(crash).not_to be_nil
|
|
468
|
+
ship = crash[:state][:ship]
|
|
469
|
+
expect(crash[:state][:rocks].any? do |rock|
|
|
470
|
+
dx = PWN::Banner.send(:mini_delta, from: ship[0], to: rock[0], span: 5)
|
|
471
|
+
dy = PWN::Banner.send(:mini_delta, from: ship[1], to: rock[1], span: 5)
|
|
472
|
+
(dx * dx) + (dy * dy) <= (rock[4] + 0.55)**2
|
|
473
|
+
end).to be(true)
|
|
474
|
+
wrecks = frames.select { |frame| frame[:state][:explosion].to_i.positive? }
|
|
475
|
+
expect(wrecks.length).to be >= 8
|
|
476
|
+
expect(wrecks.map { |frame| frame[:rows] }.uniq.length).to be >= 4
|
|
477
|
+
wrecks.each do |frame|
|
|
478
|
+
expect(game.cells(frame).flatten.any? { |cell| cell[:foreground] == :cyan && cell[:glyph] != ' ' }).to be(false)
|
|
479
|
+
expect(game.cells(frame).flatten.any? { |cell| %i[red yellow white].include?(cell[:foreground]) && cell[:glyph] != ' ' }).to be(true)
|
|
480
|
+
end
|
|
481
|
+
game.key(:up)
|
|
482
|
+
game.key(' ')
|
|
483
|
+
frozen = game.step
|
|
484
|
+
expect(frozen[:state][:ship]).to eq(ship)
|
|
485
|
+
expect(frozen[:state][:shots]).to be_empty
|
|
486
|
+
recovery = Array.new(15) { game.step }.find { |frame| frame[:event] == :recover }
|
|
487
|
+
expect(recovery).not_to be_nil
|
|
488
|
+
expect(recovery[:state][:invulnerable]).to be_positive
|
|
489
|
+
game.key(:up)
|
|
490
|
+
expect(game.step[:state][:ship]).not_to eq(ship)
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
it 'turns and thrusts Asteroids only on arrows and fires only on space' do
|
|
494
|
+
game = PWN::Banner::MiniGame.new(name: :asteroids, width: 16, height: 16, seed: 73)
|
|
495
|
+
first = game.step[:state]
|
|
496
|
+
idle = game.step[:state]
|
|
497
|
+
expect(idle[:ship]).to eq(first[:ship])
|
|
498
|
+
expect(idle[:shots]).to be_empty
|
|
499
|
+
game.key(:left)
|
|
500
|
+
left = game.step[:state]
|
|
501
|
+
expect(left[:ship][2]).to be_within(0.001).of((first[:ship][2] - (Math::PI / 4)) % (Math::PI * 2))
|
|
502
|
+
game.key(:right)
|
|
503
|
+
expect(game.step[:state][:ship][2]).to be_within(0.001).of(first[:ship][2])
|
|
504
|
+
game.key(:up)
|
|
505
|
+
moved = game.step[:state]
|
|
506
|
+
expect(moved[:ship].take(2)).not_to eq(first[:ship].take(2))
|
|
507
|
+
distance = moved[:ship].take(2).zip(first[:ship]).sum { |a, b| (a - b)**2 }
|
|
508
|
+
expect(Math.sqrt(distance)).to be >= 0.079
|
|
509
|
+
expect(moved[:thrust]).to be(true)
|
|
510
|
+
game.key(:down)
|
|
511
|
+
expect(game.step[:state][:thrust]).to be(true)
|
|
512
|
+
game.key(' ')
|
|
513
|
+
expect(game.step[:state][:shots].length).to eq(1)
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
describe PWN::Banner do
|
|
29
518
|
def block_colors(rows)
|
|
30
519
|
rows.flat_map do |row|
|
|
31
520
|
[0, 2].map do |bit|
|
|
@@ -154,7 +643,17 @@ describe PWN::Banner do
|
|
|
154
643
|
expect(described_class::MINI_PIXELS.fetch(rows[1][2])).to eq(['▘', :red])
|
|
155
644
|
end
|
|
156
645
|
|
|
157
|
-
it 'draws a
|
|
646
|
+
it 'draws Asteroids on a finer two-by-four dot raster, including bottom and wrapped edges' do
|
|
647
|
+
board = Array.new(5) { ' ' * 5 }
|
|
648
|
+
4.times { |y| described_class.send(:mini_dot, board: board, x: 0, y: y, color: 0) }
|
|
649
|
+
described_class.send(:mini_dot, board: board, x: 9, y: 19, color: 2)
|
|
650
|
+
described_class.send(:mini_dot, board: board, x: 10, y: 20, color: 0)
|
|
651
|
+
expect(described_class::MINI_PIXELS.fetch(board[0][0])).to eq(["\u2847", :cyan])
|
|
652
|
+
expect(described_class::MINI_PIXELS.fetch(board[4][4])).to eq(["\u2880", :yellow])
|
|
653
|
+
expect(board.join.chars.reject { |pixel| pixel == ' ' }.length).to eq(2)
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
it 'draws a filled connected fine-dot ship with a solid center in every heading' do
|
|
158
657
|
8.times do |direction|
|
|
159
658
|
frames = []
|
|
160
659
|
described_class.send(:mini_asteroids_snapshot, frames: frames, width: 8, height: 7, ship: [4, 3, direction * Math::PI / 4], thrust: false, rocks: [], shots: [], sparks: [])
|
|
@@ -163,13 +662,18 @@ describe PWN::Banner do
|
|
|
163
662
|
row.chars.each_with_index do |pixel, x|
|
|
164
663
|
next if pixel == ' '
|
|
165
664
|
|
|
166
|
-
|
|
167
|
-
|
|
665
|
+
glyph = described_class::MINI_PIXELS.fetch(pixel).first
|
|
666
|
+
expect(glyph.ord).to be_between(0x2801, 0x28ff)
|
|
667
|
+
mask = glyph.ord - 0x2800
|
|
668
|
+
[[0, 0], [0, 1], [0, 2], [1, 0], [1, 1], [1, 2], [0, 3], [1, 3]].each_with_index do |(dx, dy), bit|
|
|
669
|
+
points << [(x * 2) + dx, (y * 4) + dy] if mask[bit] == 1
|
|
670
|
+
end
|
|
168
671
|
end
|
|
169
672
|
end
|
|
170
|
-
expect(points.length).to
|
|
171
|
-
expect(points
|
|
172
|
-
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)
|
|
173
677
|
connected = [points.shift]
|
|
174
678
|
loop do
|
|
175
679
|
adjacent = points.select { |point| connected.any? { |other| point.zip(other).all? { |a, b| (a - b).abs <= 1 } } }
|
|
@@ -207,27 +711,27 @@ describe PWN::Banner do
|
|
|
207
711
|
end
|
|
208
712
|
end
|
|
209
713
|
|
|
210
|
-
it 'keeps rocks small and neutral with sparse
|
|
714
|
+
it 'keeps rocks small and neutral with sparse fine-dot shots and thrust' do
|
|
211
715
|
frames = []
|
|
212
716
|
described_class.send(:mini_asteroids_snapshot, frames: frames, width: 8, height: 7, ship: [4, 3, 0], thrust: false,
|
|
213
717
|
rocks: [[1, 1, 0, 0, 0.5, 1]], shots: [], sparks: [])
|
|
214
|
-
rock_pixels = frames.first[:rows].drop(1).flat_map(&:chars).reject { |pixel| pixel == ' ' || (pixel.ord -
|
|
718
|
+
rock_pixels = frames.first[:rows].drop(1).flat_map(&:chars).reject { |pixel| pixel == ' ' || (pixel.ord - 4096) / 256 == 0 }
|
|
215
719
|
expect(rock_pixels.map { |pixel| described_class::MINI_PIXELS.fetch(pixel)[1] }.uniq).to eq([:white])
|
|
216
|
-
expect(rock_pixels.sum { |pixel| ((pixel.ord -
|
|
720
|
+
expect(rock_pixels.sum { |pixel| ((pixel.ord - 4096) % 256).digits(2).sum }).to eq(4)
|
|
217
721
|
end
|
|
218
722
|
|
|
219
|
-
it 'keeps a
|
|
723
|
+
it 'keeps a solid fine-dot ship and sparse subcell occupancy throughout tiny seeded replays' do
|
|
220
724
|
[5, 8, 16].product([1, 73, 74]).each do |size, seed|
|
|
221
725
|
frames = described_class.send(:mini_replay, name: :asteroids, width: size, height: size, seed: seed)
|
|
222
726
|
frames.each do |frame|
|
|
223
727
|
next unless frame[:state][:ship]
|
|
224
728
|
|
|
225
729
|
pixels = frame[:rows].drop(1).flat_map(&:chars).reject { |pixel| pixel == ' ' }
|
|
226
|
-
area = pixels.sum { |pixel| ((pixel.ord -
|
|
227
|
-
ship = pixels.select { |pixel| (pixel.ord -
|
|
228
|
-
expect(ship.sum { |pixel| ((pixel.ord -
|
|
229
|
-
expect(area).to be <=
|
|
230
|
-
# 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
|
|
231
735
|
# character rectangles, while bounding the number of colored cells too.
|
|
232
736
|
expect(pixels.length).to be <= 14
|
|
233
737
|
end
|
|
@@ -250,7 +754,7 @@ describe PWN::Banner do
|
|
|
250
754
|
[5, 8, 16].each do |size|
|
|
251
755
|
frames = described_class.send(:mini_replay, name: :asteroids, width: size, height: size, seed: 73)
|
|
252
756
|
thrust = frames.select { |frame| frame[:state][:thrust] }
|
|
253
|
-
visible = thrust.count { |frame| frame[:rows].drop(1).join.chars.any? { |pixel| (pixel.ord -
|
|
757
|
+
visible = thrust.count { |frame| frame[:rows].drop(1).join.chars.any? { |pixel| (pixel.ord - 4096) / 256 == 5 } }
|
|
254
758
|
expect(visible.to_f / thrust.length).to be >= 0.95
|
|
255
759
|
end
|
|
256
760
|
end
|
|
@@ -345,7 +849,7 @@ describe PWN::Banner do
|
|
|
345
849
|
expect(frames.flatten(1).map(&:length)).to all(eq(size))
|
|
346
850
|
frames.flatten(2).each do |cell|
|
|
347
851
|
expect(cell.keys).to contain_exactly(:glyph, :foreground, :background)
|
|
348
|
-
expect(cell[:glyph]).to match(/\A[ PWN
|
|
852
|
+
expect(cell[:glyph]).to match(/\A[ PWN█▀▄▌▐░▒·●▘▝▖▗▞▛▚▜▙▟ᗧᗤᗢᗣ│•✹═≈▰▱\u2800-\u28ff]\z/)
|
|
349
853
|
expect(%i[black red green yellow blue magenta cyan white]).to include(cell[:foreground], cell[:background])
|
|
350
854
|
end
|
|
351
855
|
expect(described_class.mini_cells(**args, frame: described_class::MINI_FRAME_COUNT)).to eq(frames.first)
|
|
@@ -551,7 +1055,7 @@ end
|
|
|
551
1055
|
describe PWN::Banner do
|
|
552
1056
|
describe '.mini_frame' do
|
|
553
1057
|
it 'offers retro games with deterministic cycles and branding at every square size' do
|
|
554
|
-
expect(described_class.mini_names).to eq(%i[falling_blocks snake
|
|
1058
|
+
expect(described_class.mini_names).to eq(%i[falling_blocks snake pacman asteroids galaga frogger])
|
|
555
1059
|
described_class.mini_names.each do |name|
|
|
556
1060
|
(5..16).each do |size|
|
|
557
1061
|
args = { name: name, width: size, height: size }
|
|
@@ -598,7 +1102,7 @@ describe PWN::Banner do
|
|
|
598
1102
|
changed.clear
|
|
599
1103
|
described_class.mini_names.clear
|
|
600
1104
|
expect(described_class.mini_frame).to eq(original)
|
|
601
|
-
expect(described_class.mini_names.length).to eq(
|
|
1105
|
+
expect(described_class.mini_names.length).to eq(6)
|
|
602
1106
|
end
|
|
603
1107
|
|
|
604
1108
|
it 'keeps recognizable pieces, bodies and paddles in the smallest complete canvas' do
|