Files
functional-programming-snake/scala/solutions/pong/Rules.scala
T
Lucas WintherandClaude Fable 5.2 edd57f0148 feat(course): audit fixes and twelve extensions (Modules 7b, 12, 13)
Audit pass:
- Frame rule counts frames since the last move, so every step lasts
  exactly movesEvery(score) frames, also right after a speed-up
- clampTime + MAX_GAP_MS: a hidden tab no longer replays the whole gap
- Pong Restart keeps held keys
- Fact corrections (mulberry32 output coverage, Haskell IO, uniform)
- New fixture cases from a mutation analysis

Extensions (all three tracks, shared fixtures, course text):
- Turn queue (Exercise 10.6), persisted high score (10.7),
  replay viewer (10.8), play() end-to-end helper (9.5)
- Optional State (4.7) and optional Module 7b, stack-safe IO
- Module 12, property-based testing with generators, shrinking,
  invariants and a model-based oracle
- Module 13, Conway's Life with a Click event
- JS type checking via jsconfig, JSDoc and types.d.ts
- Maintainer tools: scripts/mutate.mjs, scripts/typecheck-js.sh,
  Playwright end-to-end suite (e2e/, scripts/e2e.sh)

Docs: docs/contract.md is the canonical contract; specs and build log
under docs/superpowers.

Verified: JS 728, Scala 689, F# 691 unit tests; stubs and progression
M01-M13 and M07b; 176 end-to-end tests on Chromium and WebKit.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 12:05:29 +01:00

73 lines
3.3 KiB
Scala

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)
// A new match, but the keys are still where the player's fingers are.
case PongAction.Restart => initialPong(state.seed).copy(held = state.held)
case PongAction.Frame => frame(state)