1use rand::Rng;
8
9use crate::env::{Environment, SpaceInfo, SpaceType, StepInfo, StepResult};
10
11const LEFT_X: f32 = 0.05;
12const RIGHT_X: f32 = 0.95;
13const BALL_R: f32 = 0.015;
14const PADDLE_H: f32 = 0.1; const BALL_SPEED: f32 = 0.018;
16const PADDLE_SPEED: f32 = 0.025;
17const OPPONENT_SPEED: f32 = 0.015;
18const MAX_SCORE: u32 = 7;
19const MAX_STEPS: usize = 2000;
20
21#[derive(Debug, Clone)]
29pub struct PongState {
30 pub ball_x: f32,
32 pub ball_y: f32,
34 pub ball_dx: f32,
36 pub ball_dy: f32,
38 pub left_y: f32,
40 pub right_y: f32,
42 pub left_score: u32,
44 pub right_score: u32,
46 pub steps: usize,
48}
49
50pub struct Pong {
62 ball_x: f32,
63 ball_y: f32,
64 ball_dx: f32,
65 ball_dy: f32,
66 left_y: f32,
67 right_y: f32,
68 left_score: u32,
69 right_score: u32,
70 steps: usize,
71}
72
73impl Pong {
74 pub fn new() -> Self {
76 let mut p = Self {
77 ball_x: 0.5,
78 ball_y: 0.5,
79 ball_dx: BALL_SPEED,
80 ball_dy: 0.0,
81 left_y: 0.5,
82 right_y: 0.5,
83 left_score: 0,
84 right_score: 0,
85 steps: 0,
86 };
87 p.serve(true);
88 p
89 }
90
91 fn serve(&mut self, toward_agent: bool) {
92 let mut rng = rand::rng();
93 self.ball_x = 0.5;
94 self.ball_y = 0.3 + rng.random_range(0.0f32..0.4f32);
95 self.ball_dx = if toward_agent {
96 -BALL_SPEED
97 } else {
98 BALL_SPEED
99 };
100 self.ball_dy = rng.random_range(-0.5f32..0.5f32) * BALL_SPEED;
101 }
102
103 fn clamp_paddle(y: f32) -> f32 {
104 y.clamp(PADDLE_H, 1.0 - PADDLE_H)
105 }
106
107 pub fn get_state(&self) -> [f32; 6] {
110 [
111 self.ball_x,
112 self.ball_y,
113 self.left_y,
114 self.right_y,
115 self.left_score as f32,
116 self.right_score as f32,
117 ]
118 }
119
120 pub fn step_two(&mut self, left_action: i64, right_action: i64) -> StepResult {
132 self.left_y = match left_action {
134 0 => Self::clamp_paddle(self.left_y - PADDLE_SPEED),
135 2 => Self::clamp_paddle(self.left_y + PADDLE_SPEED),
136 _ => self.left_y,
137 };
138
139 self.right_y = match right_action {
141 0 => Self::clamp_paddle(self.right_y - PADDLE_SPEED),
142 2 => Self::clamp_paddle(self.right_y + PADDLE_SPEED),
143 _ => self.right_y,
144 };
145
146 let old_bx = self.ball_x;
147 self.ball_x += self.ball_dx;
148 self.ball_y += self.ball_dy;
149
150 if self.ball_y - BALL_R < 0.0 {
152 self.ball_y = BALL_R;
153 self.ball_dy = self.ball_dy.abs();
154 } else if self.ball_y + BALL_R > 1.0 {
155 self.ball_y = 1.0 - BALL_R;
156 self.ball_dy = -self.ball_dy.abs();
157 }
158
159 let mut reward = 0.0f32;
160
161 if old_bx - BALL_R >= LEFT_X
165 && self.ball_x - BALL_R < LEFT_X
166 && self.ball_dx < 0.0
167 && (self.ball_y - self.left_y).abs() <= PADDLE_H
168 {
169 let hit_pos = (self.ball_y - self.left_y) / PADDLE_H;
170 self.ball_dx = BALL_SPEED;
171 self.ball_dy = (self.ball_dy + hit_pos * BALL_SPEED * 0.6)
172 .clamp(-BALL_SPEED * 1.2, BALL_SPEED * 1.2);
173 self.ball_x = LEFT_X + BALL_R;
174 reward = 0.1;
175 }
176
177 if old_bx + BALL_R <= RIGHT_X
181 && self.ball_x + BALL_R > RIGHT_X
182 && self.ball_dx > 0.0
183 && (self.ball_y - self.right_y).abs() <= PADDLE_H
184 {
185 let hit_pos = (self.ball_y - self.right_y) / PADDLE_H;
186 self.ball_dx = -BALL_SPEED;
187 self.ball_dy = (self.ball_dy + hit_pos * BALL_SPEED * 0.6)
188 .clamp(-BALL_SPEED * 1.2, BALL_SPEED * 1.2);
189 self.ball_x = RIGHT_X - BALL_R;
190 }
191
192 if self.ball_x - BALL_R < 0.0 {
194 self.right_score += 1;
195 reward = -1.0;
196 let toward = rand::rng().random_bool(0.5);
197 self.serve(toward);
198 } else if self.ball_x + BALL_R > 1.0 {
199 self.left_score += 1;
200 reward = 1.0;
201 let toward = rand::rng().random_bool(0.5);
202 self.serve(toward);
203 }
204
205 self.steps += 1;
206
207 StepResult {
208 observation: self.get_observation(),
209 reward,
210 terminated: self.left_score >= MAX_SCORE || self.right_score >= MAX_SCORE,
211 truncated: self.steps >= MAX_STEPS,
212 info: StepInfo::default(),
213 }
214 }
215}
216
217pub fn mirror_observation(obs: &[f32]) -> Vec<f32> {
228 assert_eq!(obs.len(), 6, "Pong observation must be 6-dimensional");
229 vec![-obs[0], obs[1], -obs[2], obs[3], obs[5], obs[4]]
230}
231
232impl Default for Pong {
233 fn default() -> Self {
234 Self::new()
235 }
236}
237
238impl Environment for Pong {
239 type Action = i64;
240 type State = PongState;
241
242 fn reset(&mut self) {
243 self.left_y = 0.5;
244 self.right_y = 0.5;
245 self.left_score = 0;
246 self.right_score = 0;
247 self.steps = 0;
248 let toward = rand::rng().random_bool(0.5);
249 self.serve(toward);
250 }
251
252 fn get_observation(&self) -> Vec<f32> {
253 vec![
255 self.ball_x * 2.0 - 1.0,
256 self.ball_y * 2.0 - 1.0,
257 self.ball_dx / BALL_SPEED,
258 self.ball_dy / BALL_SPEED,
259 self.left_y * 2.0 - 1.0,
260 self.right_y * 2.0 - 1.0,
261 ]
262 }
263
264 fn step(&mut self, action: i64) -> StepResult {
265 self.left_y = match action {
267 0 => Self::clamp_paddle(self.left_y - PADDLE_SPEED),
268 2 => Self::clamp_paddle(self.left_y + PADDLE_SPEED),
269 _ => self.left_y,
270 };
271
272 let diff = (self.ball_y - self.right_y).clamp(-OPPONENT_SPEED, OPPONENT_SPEED);
274 self.right_y = Self::clamp_paddle(self.right_y + diff);
275
276 let old_bx = self.ball_x;
277 self.ball_x += self.ball_dx;
278 self.ball_y += self.ball_dy;
279
280 if self.ball_y - BALL_R < 0.0 {
282 self.ball_y = BALL_R;
283 self.ball_dy = self.ball_dy.abs();
284 } else if self.ball_y + BALL_R > 1.0 {
285 self.ball_y = 1.0 - BALL_R;
286 self.ball_dy = -self.ball_dy.abs();
287 }
288
289 let mut reward = 0.0f32;
290
291 if old_bx - BALL_R >= LEFT_X
295 && self.ball_x - BALL_R < LEFT_X
296 && self.ball_dx < 0.0
297 && (self.ball_y - self.left_y).abs() <= PADDLE_H
298 {
299 let hit_pos = (self.ball_y - self.left_y) / PADDLE_H;
300 self.ball_dx = BALL_SPEED;
301 self.ball_dy = (self.ball_dy + hit_pos * BALL_SPEED * 0.6)
302 .clamp(-BALL_SPEED * 1.2, BALL_SPEED * 1.2);
303 self.ball_x = LEFT_X + BALL_R;
304 reward = 0.1;
305 }
306
307 if old_bx + BALL_R <= RIGHT_X
311 && self.ball_x + BALL_R > RIGHT_X
312 && self.ball_dx > 0.0
313 && (self.ball_y - self.right_y).abs() <= PADDLE_H
314 {
315 let hit_pos = (self.ball_y - self.right_y) / PADDLE_H;
316 self.ball_dx = -BALL_SPEED;
317 self.ball_dy = (self.ball_dy + hit_pos * BALL_SPEED * 0.6)
318 .clamp(-BALL_SPEED * 1.2, BALL_SPEED * 1.2);
319 self.ball_x = RIGHT_X - BALL_R;
320 }
321
322 if self.ball_x - BALL_R < 0.0 {
324 self.right_score += 1;
325 reward = -1.0;
326 let toward = rand::rng().random_bool(0.5);
327 self.serve(toward);
328 } else if self.ball_x + BALL_R > 1.0 {
329 self.left_score += 1;
330 reward = 1.0;
331 let toward = rand::rng().random_bool(0.5);
332 self.serve(toward);
333 }
334
335 self.steps += 1;
336
337 StepResult {
338 observation: self.get_observation(),
339 reward,
340 terminated: self.left_score >= MAX_SCORE || self.right_score >= MAX_SCORE,
341 truncated: self.steps >= MAX_STEPS,
342 info: StepInfo::default(),
343 }
344 }
345
346 fn observation_space(&self) -> SpaceInfo {
347 SpaceInfo { shape: vec![6], space_type: SpaceType::Box }
348 }
349
350 fn action_space(&self) -> SpaceInfo {
351 SpaceInfo { shape: vec![3], space_type: SpaceType::Discrete(3) }
352 }
353
354 fn render(&self) -> Vec<u8> {
355 vec![]
356 }
357
358 fn close(&mut self) {}
359
360 fn clone_state(&self) -> PongState {
361 PongState {
362 ball_x: self.ball_x,
363 ball_y: self.ball_y,
364 ball_dx: self.ball_dx,
365 ball_dy: self.ball_dy,
366 left_y: self.left_y,
367 right_y: self.right_y,
368 left_score: self.left_score,
369 right_score: self.right_score,
370 steps: self.steps,
371 }
372 }
373
374 fn restore_state(&mut self, state: &PongState) {
375 self.ball_x = state.ball_x;
376 self.ball_y = state.ball_y;
377 self.ball_dx = state.ball_dx;
378 self.ball_dy = state.ball_dy;
379 self.left_y = state.left_y;
380 self.right_y = state.right_y;
381 self.left_score = state.left_score;
382 self.right_score = state.right_score;
383 self.steps = state.steps;
384 }
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 #[test]
396 fn step_two_stay_stay_freezes_paddles() {
397 let mut p = Pong::new();
398 let left_y0 = p.left_y;
399 let right_y0 = p.right_y;
400 let ball_x0 = p.ball_x;
401
402 for _ in 0..10 {
404 let _ = p.step_two(1, 1);
405 }
406
407 assert_eq!(p.left_y, left_y0, "Left paddle moved despite stay action");
409 assert_eq!(p.right_y, right_y0, "Right paddle moved — heuristic was not bypassed");
410
411 assert_ne!(p.ball_x, ball_x0, "Ball did not move during step_two");
413 }
414
415 #[test]
418 fn step_two_drives_right_paddle_independently() {
419 let mut p = Pong::new();
420 let right_y0 = p.right_y;
421
422 let _ = p.step_two(1, 0);
424 assert!(p.right_y < right_y0, "Right paddle did not move up with action=0");
425
426 let before = p.right_y;
428 let _ = p.step_two(1, 2);
429 assert!(p.right_y > before, "Right paddle did not move down with action=2");
430 }
431
432 #[test]
434 fn mirror_observation_round_trip() {
435 let p = Pong::new();
436 let obs = p.get_observation();
437 let mirrored = mirror_observation(&obs);
438 let unmirrored = mirror_observation(&mirrored);
439 assert_eq!(obs.len(), 6);
440 assert_eq!(unmirrored.len(), 6);
441 for (a, b) in obs.iter().zip(unmirrored.iter()) {
442 assert!((a - b).abs() < 1e-7, "round-trip mismatch: {a} vs {b}");
443 }
444 }
445
446 #[test]
450 fn clone_restore_round_trips() {
451 let mut env = Pong::new();
452 env.reset();
453
454 env.ball_x = 0.5;
458 env.ball_y = 0.5;
459 env.ball_dx = BALL_SPEED;
460 env.ball_dy = 0.0;
461 env.left_y = 0.5;
462 env.right_y = 0.5;
463 env.left_score = 0;
464 env.right_score = 0;
465 env.steps = 0;
466
467 for _ in 0..3 {
470 env.step(1);
471 }
472 let snap = env.clone_state();
473
474 let r1 = env.step(2);
476
477 env.restore_state(&snap);
479 let r2 = env.step(2);
480
481 assert_eq!(r1.observation, r2.observation);
484 assert_eq!(r1.reward, r2.reward);
485 assert_eq!(r1.terminated, r2.terminated);
486 assert_eq!(r1.truncated, r2.truncated);
487 }
488
489 #[test]
492 fn mirror_observation_semantics() {
493 let obs = vec![0.3, 0.4, 0.5, 0.6, 0.7, 0.8];
494 let m = mirror_observation(&obs);
495 assert_eq!(m, vec![-0.3, 0.4, -0.5, 0.6, 0.8, 0.7]);
496 }
497}