From f4b671678e1539a12c79d528a2476d6a22e8514b Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 29 Oct 2026 00:15:33 +0100 Subject: [PATCH] feat(scala): m11 pong capstone (model, rules, view, app) with tests Co-Authored-By: Claude Opus 5.5 --- scala/solutions/pong/App.scala | 32 ++++++++ scala/solutions/pong/Model.scala | 73 +++++++++++++++++ scala/solutions/pong/Rules.scala | 71 +++++++++++++++++ scala/solutions/pong/View.scala | 29 +++++++ scala/src/pong/App.scala | 27 +++++++ scala/src/pong/Model.scala | 67 ++++++++++++++++ scala/src/pong/Rules.scala | 32 ++++++++ scala/src/pong/View.scala | 17 ++++ scala/test/M11Pong.test.scala | 130 +++++++++++++++++++++++++++++++ scripts/check-progression.sh | 2 +- 10 files changed, 479 insertions(+), 1 deletion(-) create mode 100644 scala/solutions/pong/App.scala create mode 100644 scala/solutions/pong/Model.scala create mode 100644 scala/solutions/pong/Rules.scala create mode 100644 scala/solutions/pong/View.scala create mode 100644 scala/src/pong/App.scala create mode 100644 scala/src/pong/Model.scala create mode 100644 scala/src/pong/Rules.scala create mode 100644 scala/src/pong/View.scala create mode 100644 scala/test/M11Pong.test.scala diff --git a/scala/solutions/pong/App.scala b/scala/solutions/pong/App.scala new file mode 100644 index 0000000..3c6b8a5 --- /dev/null +++ b/scala/solutions/pong/App.scala @@ -0,0 +1,32 @@ +package snake +package pong + +import PongModel.initialPong + +/** m11 pong app: the same wiring as App.scala (streams in, drawings out), reusing the course's modules. */ +object PongApp: + + val FRAME_MS_PONG: Long = 30 + + private def paddleKey(key: String): Option[Paddle] = key match + case "ArrowUp" | "w" | "W" => Some(Paddle.Up) + case "ArrowDown" | "s" | "S" => Some(Paddle.Down) + case _ => None + + def pongKey(isDown: Boolean, key: String): Option[PongAction] = + paddleKey(key) match + case Some(p) => Some(if isDown then PongAction.Press(p) else PongAction.Release(p)) + case None => if isDown && (key == "r" || key == "R") then Some(PongAction.Restart) else None + + lazy val pongActions: Stream[PongAction] = + Stream + .interval(FRAME_MS_PONG) + .map(_ => PongAction.Frame) + .merge(Stream.keyDowns.filterMap(pongKey(true, _))) + .merge(Stream.keyUps.filterMap(pongKey(false, _))) + + def pongStates(seed: Long, actions: Stream[PongAction]): Stream[PongState] = + actions.scan(initialPong(seed))(PongRules.pongUpdate).startWith(initialPong(seed)) + + lazy val pongMain: IO[Stream[IO[Unit]]] = + IO.randomSeed.map(seed => pongStates(seed, pongActions).map(state => IO.drawScene(PongView.pongView(state)))) diff --git a/scala/solutions/pong/Model.scala b/scala/solutions/pong/Model.scala new file mode 100644 index 0000000..40a0b3a --- /dev/null +++ b/scala/solutions/pong/Model.scala @@ -0,0 +1,73 @@ +package snake +package pong + +// m11 pong model: the same ideas as the snake model, for a different game. + +/** The ball's direction of travel, one cell per frame on each axis. */ +final case class Velocity(dx: Int, dy: Int) + +/** Which of the player's two keys are being held down right now. */ +final case class Held(up: Boolean, down: Boolean) + +final case class Score(player: Int, cpu: Int) + +enum PongStatus: + case Playing, Over + +/** The two keys that move the player's paddle. */ +enum Paddle: + case Up, Down + +/** + * A whole match. Positions are in cells: `ball` is a cell of the 80x50 court; `player` and `cpu` are the y of the + * top cell of each paddle. `seed` is the unsigned 32-bit random seed used to pick the direction of each serve. + */ +final case class PongState( + ball: Point, + vel: Velocity, + player: Int, + cpu: Int, + held: Held, + score: Score, + status: PongStatus, + seed: Long, + frame: Int +) + +/** Everything that can happen in a match, as data. */ +enum PongAction: + case Press(key: Paddle) + case Release(key: Paddle) + case Frame + case Restart + +object PongModel: + + // The court, in cells. + val W: Int = 80 + val H: Int = 50 + + /** Pixels per cell. */ + val PCELL: Int = 8 + + /** Paddle height in cells. */ + val PADDLE: Int = 8 + + /** Column of the player's paddle (left) and of the cpu's paddle (right). */ + val PLAYER_X: Int = 2 + val CPU_X: Int = 77 + + /** Points needed to win the match. */ + val WIN: Int = 5 + + def initialPong(seed: Long): PongState = PongState( + ball = Point(40, 25), + vel = Velocity(1, 1), + player = 21, + cpu = 21, + held = Held(up = false, down = false), + score = Score(player = 0, cpu = 0), + status = PongStatus.Playing, + seed = seed, + frame = 0 + ) diff --git a/scala/solutions/pong/Rules.scala b/scala/solutions/pong/Rules.scala new file mode 100644 index 0000000..a736a68 --- /dev/null +++ b/scala/solutions/pong/Rules.scala @@ -0,0 +1,71 @@ +package snake +package pong + +import PongModel.* + +/** m11 pong rules: one pure `pongUpdate`, built from small steps that each return a new piece of state. */ +object PongRules: + + def clamp(lo: Int, hi: Int, x: Int): Int = math.min(hi, math.max(lo, x)) + + private def clampPaddle(y: Int): Int = clamp(0, H - PADDLE, y) + + private def movePlayer(state: PongState): Int = + clampPaddle(state.player + (if state.held.down then 1 else 0) - (if state.held.up then 1 else 0)) + + // The cpu chases the middle of its paddle toward the ball, one cell every other frame. + private def moveCpu(state: PongState, frame: Int): Int = + if frame % 2 == 0 then clampPaddle(state.cpu + (state.ball.y - PADDLE / 2 - state.cpu).sign) + else state.cpu + + private def hits(paddle: Int, y: Int): Boolean = paddle <= y && y < paddle + PADDLE + + private enum Side: + case Player, Cpu + + // Where the ball goes next, given the paddles' new positions, and who scored, if anyone. + private def moveBall(state: PongState, player: Int, cpu: Int): (Point, Velocity, Option[Side]) = + val (ball, vel) = (state.ball, state.vel) + val wall = ball.y + vel.dy < 0 || ball.y + vel.dy >= H + val dy = if wall then -vel.dy else vel.dy + val ny = ball.y + dy + val nx = ball.x + vel.dx + val bounce = + (nx == PLAYER_X && vel.dx < 0 && hits(player, ny)) || + (nx == CPU_X && vel.dx > 0 && hits(cpu, ny)) + val scorer = if nx < 0 then Some(Side.Cpu) else if nx >= W then Some(Side.Player) else None + (Point(if bounce then ball.x else nx, ny), Velocity(if bounce then -vel.dx else vel.dx, dy), scorer) + + // After a point the ball restarts in the middle, heading toward whoever conceded; the seed picks up or down. + private def serve(state: PongState, scorer: Side): PongState = + val (r, seed) = Random.nextUint32.run(state.seed) + val score = scorer match + case Side.Player => state.score.copy(player = state.score.player + 1) + case Side.Cpu => state.score.copy(cpu = state.score.cpu + 1) + state.copy( + ball = Point(40, 25), + vel = Velocity(if scorer == Side.Cpu then -1 else 1, if r % 2 == 0 then 1 else -1), + score = score, + seed = seed, + status = if math.max(score.player, score.cpu) >= WIN then PongStatus.Over else PongStatus.Playing + ) + + private def frame(state: PongState): PongState = + if state.status != PongStatus.Playing then state + else + val n = state.frame + 1 + val player = movePlayer(state) + val cpu = moveCpu(state, n) + val (ball, vel, scorer) = moveBall(state, player, cpu) + val next = state.copy(ball = ball, vel = vel, player = player, cpu = cpu, frame = n) + scorer.fold(next)(serve(next, _)) + + private def setHeld(state: PongState, key: Paddle, value: Boolean): PongState = key match + case Paddle.Up => state.copy(held = state.held.copy(up = value)) + case Paddle.Down => state.copy(held = state.held.copy(down = value)) + + def pongUpdate(state: PongState, action: PongAction): PongState = action match + case PongAction.Press(key) => setHeld(state, key, true) + case PongAction.Release(key) => setHeld(state, key, false) + case PongAction.Restart => initialPong(state.seed) + case PongAction.Frame => frame(state) diff --git a/scala/solutions/pong/View.scala b/scala/solutions/pong/View.scala new file mode 100644 index 0000000..474a4f0 --- /dev/null +++ b/scala/solutions/pong/View.scala @@ -0,0 +1,29 @@ +package snake +package pong + +import PongModel.* + +/** m11 pong view: the same Scene shapes as the snake view, so the same drawScene and runtime draw it. */ +object PongView: + + private def text(x: Int, y: Int, str: String, size: Int): Shape = Shape.Text(x, y, str, "#ffffff", size, "center") + + private def paddle(column: Int, y: Int): Shape = + Shape.Rect(column * PCELL, y * PCELL, PCELL, PADDLE * PCELL, "#ffffff") + + private def overlay(state: PongState): Scene = state.status match + case PongStatus.Over => + List( + text(320, 200, if state.score.player > state.score.cpu then "You Win!" else "CPU Wins", 32), + text(320, 230, "Press R to restart", 16) + ) + case PongStatus.Playing => Nil + + def pongView(state: PongState): Scene = + List( + Shape.Rect(0, 0, 640, 400, "#111111"), + paddle(PLAYER_X, state.player), + paddle(CPU_X, state.cpu), + Shape.Rect(state.ball.x * PCELL, state.ball.y * PCELL, PCELL, PCELL, "#ffeb3b"), + text(320, 30, s"${state.score.player} ${state.score.cpu}", 24) + ) ++ overlay(state) diff --git a/scala/src/pong/App.scala b/scala/src/pong/App.scala new file mode 100644 index 0000000..5416440 --- /dev/null +++ b/scala/src/pong/App.scala @@ -0,0 +1,27 @@ +package snake +package pong + +/** m11 pong app: the same wiring as App.scala (streams in, drawings out), reusing the course's modules. */ +object PongApp: + + /** Milliseconds between frames of the match. */ + val FRAME_MS_PONG: Long = 30 + + /** + * Translate a key event into a `PongAction`, if any. `isDown` is true for a key press, false for a release. + * "ArrowUp"/"w"/"W" -> `Press(Paddle.Up)` / `Release(Paddle.Up)`, "ArrowDown"/"s"/"S" -> `Press(Paddle.Down)` / + * `Release(Paddle.Down)`, "r"/"R" -> `Restart` (on press only; releasing r does nothing). Any other key -> `None`. + */ + def pongKey(isDown: Boolean, key: String): Option[PongAction] = ??? + + /** + * Everything that can happen in a match: `Frame` every FRAME_MS_PONG (from `Stream.interval`), merged with the + * `pongKey(true, key)` of every key press and the `pongKey(false, key)` of every key release. + */ + lazy val pongActions: Stream[PongAction] = ??? + + /** Fold the actions into a stream of states: start from `initialPong(seed)`, `pongUpdate` for each action, emit the initial state first. */ + def pongStates(seed: Long, actions: Stream[PongAction]): Stream[PongState] = ??? + + /** The whole program as a value, like `App.main`: ask for a seed, turn the stream of states into a stream of drawings of `pongView`. */ + lazy val pongMain: IO[Stream[IO[Unit]]] = ??? diff --git a/scala/src/pong/Model.scala b/scala/src/pong/Model.scala new file mode 100644 index 0000000..a596a2a --- /dev/null +++ b/scala/src/pong/Model.scala @@ -0,0 +1,67 @@ +package snake +package pong + +// m11 pong model: the same ideas as the snake model, for a different game. + +/** The ball's direction of travel, one cell per frame on each axis. */ +final case class Velocity(dx: Int, dy: Int) + +/** Which of the player's two keys are being held down right now. */ +final case class Held(up: Boolean, down: Boolean) + +final case class Score(player: Int, cpu: Int) + +enum PongStatus: + case Playing, Over + +/** The two keys that move the player's paddle. */ +enum Paddle: + case Up, Down + +/** + * A whole match. Positions are in cells: `ball` is a cell of the 80x50 court; `player` and `cpu` are the y of the + * top cell of each paddle. `seed` is the unsigned 32-bit random seed used to pick the direction of each serve. + */ +final case class PongState( + ball: Point, + vel: Velocity, + player: Int, + cpu: Int, + held: Held, + score: Score, + status: PongStatus, + seed: Long, + frame: Int +) + +/** Everything that can happen in a match, as data. */ +enum PongAction: + case Press(key: Paddle) + case Release(key: Paddle) + case Frame + case Restart + +object PongModel: + + // The court, in cells. + val W: Int = 80 + val H: Int = 50 + + /** Pixels per cell. */ + val PCELL: Int = 8 + + /** Paddle height in cells. */ + val PADDLE: Int = 8 + + /** Column of the player's paddle (left) and of the cpu's paddle (right). */ + val PLAYER_X: Int = 2 + val CPU_X: Int = 77 + + /** Points needed to win the match. */ + val WIN: Int = 5 + + /** + * A fresh match: ball at (40, 25) moving `Velocity(1, 1)`, both paddles at y 21, no keys held, + * score 0 to 0, status Playing, the given seed, frame 0. + */ + def initialPong(seed: Long): PongState = ??? diff --git a/scala/src/pong/Rules.scala b/scala/src/pong/Rules.scala new file mode 100644 index 0000000..f3a00a2 --- /dev/null +++ b/scala/src/pong/Rules.scala @@ -0,0 +1,32 @@ +package snake +package pong + +import PongModel.* + +/** m11 pong rules: one pure `pongUpdate`. */ +object PongRules: + + /** Keep `x` inside `[lo, hi]`. */ + def clamp(lo: Int, hi: Int, x: Int): Int = ??? + + /** + * The one function that says how a match changes: `(state, action) => state`. + * + * - `Press(key)` / `Release(key)`: set `held.up` or `held.down` to true / false (even when the match is over). + * - `Restart`: `initialPong(state.seed)`. + * - `Frame`: unchanged unless status is Playing. Otherwise, in this order: + * 1. `frame + 1`. + * 2. The player moves one cell down for `held.down` and one up for `held.up` (both cancel out), + * kept within `[0, H - PADDLE]`. + * 3. When the new `frame` is even, the cpu moves one cell toward `ball.y - PADDLE / 2` (it stays put when it is + * already there), kept within `[0, H - PADDLE]`. It looks at the ball before the ball moves. + * 4. The ball moves vertically: `ny = y + dy`; if `ny < 0 || ny >= H` then `dy` flips and `ny = y + dy`. + * 5. Horizontally: `nx = x + dx`. It bounces (`dx` flips, `nx = x`) when `nx == PLAYER_X`, `dx < 0` and + * `player <= ny < player + PADDLE` (using the player's new position), or when `nx == CPU_X`, `dx > 0` and + * `cpu <= ny < cpu + PADDLE`. + * 6. If `nx < 0` the cpu scores; if `nx >= W` the player scores. A point serves a new ball: position (40, 25), + * `dx` toward the side that conceded (cpu scored -> -1, player scored -> +1), `dy = 1` when the value of + * `Random.nextUint32.run(seed)` is even and `-1` otherwise, and the seed advances. + * 7. A score of `WIN` ends the match: status Over. + */ + def pongUpdate(state: PongState, action: PongAction): PongState = ??? diff --git a/scala/src/pong/View.scala b/scala/src/pong/View.scala new file mode 100644 index 0000000..9dc99d0 --- /dev/null +++ b/scala/src/pong/View.scala @@ -0,0 +1,17 @@ +package snake +package pong + +import PongModel.* + +/** m11 pong view: the same Scene shapes as the snake view, so the same drawScene and runtime draw it. */ +object PongView: + + /** + * Describe the match as shapes, drawn in order (all text is "#ffffff", align "center"): + * background `Rect(0, 0, 640, 400, "#111111")`; the player paddle `Rect(PLAYER_X*8, player*8, 8, 64, "#ffffff")`, + * then the cpu paddle the same way at CPU_X; the ball `Rect(x*8, y*8, 8, 8, "#ffeb3b")`; + * the score `Text(320, 30, " " (three spaces), size 24)`. + * When Over: `Text(320, 200, "You Win!" if the player has more points, else "CPU Wins", size 32)` and + * `Text(320, 230, "Press R to restart", size 16)`. + */ + def pongView(state: PongState): Scene = ??? diff --git a/scala/test/M11Pong.test.scala b/scala/test/M11Pong.test.scala new file mode 100644 index 0000000..9bb2f47 --- /dev/null +++ b/scala/test/M11Pong.test.scala @@ -0,0 +1,130 @@ +package snake +package pong + +import Fixtures.* +import PongModel.* +import PongRules.{pongUpdate, clamp} +import PongView.pongView +import PongApp.* +import M09Helpers.drawn + +class M11PongSuite extends munit.FunSuite: + + private def pongState(v: ujson.Value): PongState = PongState( + ball = point(v("ball")), + vel = Velocity(v("vel")("dx").num.toInt, v("vel")("dy").num.toInt), + player = v("player").num.toInt, + cpu = v("cpu").num.toInt, + held = Held(v("held")("up").bool, v("held")("down").bool), + score = Score(v("score")("player").num.toInt, v("score")("cpu").num.toInt), + status = PongStatus.valueOf(v("status").str), + seed = v("seed").num.toLong, + frame = v("frame").num.toInt + ) + + // `Paddle` only has Up and Down, so a JSON key like "constructor" decodes to None instead of crashing. + private def paddle(v: ujson.Value): Option[Paddle] = Paddle.values.find(_.toString == v.str) + + /** Decode a PongAction; `None` when its key is not a `Paddle` (such an action cannot even be built in Scala). */ + private def pongAction(v: ujson.Value): Option[PongAction] = v("tag").str match + case "Press" => paddle(v("key")).map(PongAction.Press(_)) + case "Release" => paddle(v("key")).map(PongAction.Release(_)) + case "Frame" => Some(PongAction.Frame) + case "Restart" => Some(PongAction.Restart) + case other => throw new IllegalArgumentException(s"unknown pong action tag: $other") + + cases("m11-pong-update").foreach { c => + test(s"pongUpdate: ${c("name").str}") { + val a = c("args") + val state = pongState(a(0)) + pongAction(a(1)) match + case Some(action) => assertEquals(pongUpdate(state, action), pongState(c("expected"))) + // An unknown key is ruled out by the types: the Paddle enum has no such case. What the + // fixture asks for (state unchanged) therefore holds by construction; check that it says so. + case None => assertEquals(pongState(c("expected")), state) + } + } + + cases("m11-pong-view").foreach { c => + test(s"pongView: ${c("name").str}") { + assertEquals(pongView(pongState(c("args")(0))), list(c("expected"))(shape)) + } + } + + cases("m11-pong-key").foreach { c => + test(s"pongKey: ${c("name").str}") { + val a = c("args") + val expected = opt(c("expected"))(e => pongAction(e).getOrElse(fail(s"undecodable expected action: $e"))) + assertEquals(pongKey(a(0).bool, a(1).str), expected) + } + } + + private def time(t: Long): Event = Event.Time(t) + private def down(key: String): Event = Event.KeyDown(key) + private def up(key: String): Event = Event.KeyUp(key) + + test("the court is 80 by 50 cells of 8 pixels, with 8-cell paddles") { + assertEquals(List(W, H, PCELL, PADDLE, PLAYER_X, CPU_X, WIN), List(80, 50, 8, 8, 2, 77, 5)) + } + + test("initialPong puts the ball in the middle, both paddles centred, and nobody ahead") { + assertEquals( + initialPong(5), + PongState(Point(40, 25), Velocity(1, 1), 21, 21, Held(false, false), Score(0, 0), PongStatus.Playing, 5, 0) + ) + } + + test("clamp keeps a number inside [lo, hi]") { + assertEquals(List(clamp(0, 42, -1), clamp(0, 42, 17), clamp(0, 42, 43)), List(0, 17, 42)) + } + + test("pongUpdate does not modify the state it is given") { + val s = initialPong(0) + pongUpdate(s, PongAction.Frame) + assertEquals(s, initialPong(0)) + } + + // 3000 frames with no one touching the keys: the ball must always stay on the court and the match must end. + test("an idle match stays on the court and ends once someone reaches five") { + val states = List.fill(3000)(PongAction.Frame).scanLeft(initialPong(3))(pongUpdate) + assert(states.forall(s => s.ball.y >= 0 && s.ball.y < H && s.ball.x >= 0 && s.ball.x < W)) + assert(states.forall(s => s.player >= 0 && s.player <= H - PADDLE && s.cpu >= 0 && s.cpu <= H - PADDLE)) + val last = states.last + assertEquals(last.status, PongStatus.Over) + assertEquals(math.max(last.score.player, last.score.cpu), WIN) + } + + test("the clock runs every 30 ms") { assertEquals(FRAME_MS_PONG, 30L) } + + test("pongActions turns the clock into Frames and keys into Press, Release and Restart") { + assertEquals( + pongActions.run(List(time(30), down("ArrowUp"), up("ArrowUp"), down("s"), down("r"), up("r"), down("x"), time(90))), + List( + PongAction.Frame, + PongAction.Press(Paddle.Up), + PongAction.Release(Paddle.Up), + PongAction.Press(Paddle.Down), + PongAction.Restart, + PongAction.Frame, + PongAction.Frame + ) + ) + } + + test("pongStates starts with the new match, then yields one state per action") { + val out = pongStates(42, pongActions).run(List(time(30), down("w"), time(60))) + assertEquals(out.length, 4) + assertEquals(out.head, initialPong(42)) + assertEquals(out.map(_.frame), List(0, 1, 1, 2)) + assertEquals(out.map(_.held.up), List(false, false, true, true)) + assertEquals(out(3).player, 20) + } + + test("pongMain asks for a seed, draws the new match first, and then the updated one") { + val traced = IO.trace(pongMain, 42) + assertEquals(traced.ops, List(Op.RandomSeed)) + val (first, next) = traced.result.step(time(0)) + val (second, _) = next.step(time(30)) + assertEquals(drawn(first.head), pongView(initialPong(42))) + assertEquals(drawn(second.head), pongView(pongUpdate(initialPong(42), PongAction.Frame))) + } diff --git a/scripts/check-progression.sh b/scripts/check-progression.sh index f04d929..a39a9f6 100755 --- a/scripts/check-progression.sh +++ b/scripts/check-progression.sh @@ -10,7 +10,7 @@ repo="$(cd "$(dirname "$0")/.." && pwd)" export PATH="$HOME/.dotnet:$HOME/.local/bin:$PATH" # Module -> files (relative to src/ and solutions/) that the module introduces. A directory entry copies it whole. -# Later tasks: extend the entries for m07+ in the Scala and F# tables. +# Later tasks: extend the F# table. files_for() { case "$track:$1" in js:01) echo "util.js todo.js" ;;