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>
95 lines
3.5 KiB
FSharp
95 lines
3.5 KiB
FSharp
/// m11 pong rules: one pure `pongUpdate`, built from small steps that each return a new piece of state.
|
|
module Snake.Pong.Rules
|
|
|
|
open Snake
|
|
open Snake.Model
|
|
open Snake.Pong.Model
|
|
|
|
let clamp (lo: int) (hi: int) (x: int) : int = min hi (max lo x)
|
|
|
|
let private clampPaddle y = clamp 0 (H - PADDLE) y
|
|
|
|
let private 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.
|
|
let private moveCpu (state: PongState) (frame: int) : int =
|
|
if frame % 2 = 0 then
|
|
clampPaddle (state.Cpu + sign (state.Ball.Y - PADDLE / 2 - state.Cpu))
|
|
else
|
|
state.Cpu
|
|
|
|
let private hits (paddle: int) (y: int) = paddle <= y && y < paddle + PADDLE
|
|
|
|
type private Scorer =
|
|
| PlayerScored
|
|
| CpuScored
|
|
|
|
// Where the ball goes next, given the paddles' new positions, and who scored, if anyone.
|
|
let private moveBall (state: PongState) (player: int) (cpu: int) : Point * Velocity * Scorer option =
|
|
let ball, vel = state.Ball, state.Vel
|
|
let wall = ball.Y + vel.Dy < 0 || ball.Y + vel.Dy >= H
|
|
let dy = if wall then -vel.Dy else vel.Dy
|
|
let ny = ball.Y + dy
|
|
let nx = ball.X + vel.Dx
|
|
|
|
let bounce =
|
|
(nx = PLAYER_X && vel.Dx < 0 && hits player ny) || (nx = CPU_X && vel.Dx > 0 && hits cpu ny)
|
|
|
|
let scorer =
|
|
if nx < 0 then Some CpuScored
|
|
elif nx >= W then Some PlayerScored
|
|
else None
|
|
|
|
({ X = (if bounce then ball.X else nx); Y = ny }, { Dx = (if bounce then -vel.Dx else vel.Dx); Dy = dy }, scorer)
|
|
|
|
// After a point the ball restarts in the middle, heading toward whoever conceded; the seed picks up or down.
|
|
let private serve (state: PongState) (scorer: Scorer) : PongState =
|
|
let r, seed = Random.run state.Seed Random.nextUint32
|
|
|
|
let score =
|
|
match scorer with
|
|
| PlayerScored -> { state.Score with Player = state.Score.Player + 1 }
|
|
| CpuScored -> { state.Score with Cpu = state.Score.Cpu + 1 }
|
|
|
|
{ state with
|
|
Ball = { X = 40; Y = 25 }
|
|
Vel = { Dx = (if scorer = CpuScored then -1 else 1); Dy = (if r % 2u = 0u then 1 else -1) }
|
|
Score = score
|
|
Seed = seed
|
|
Status = if max score.Player score.Cpu >= WIN then PongStatus.Over else PongStatus.Playing }
|
|
|
|
let private frame (state: PongState) : PongState =
|
|
if state.Status <> PongStatus.Playing then
|
|
state
|
|
else
|
|
let n = state.Frame + 1
|
|
let player = movePlayer state
|
|
let cpu = moveCpu state n
|
|
let ball, vel, scorer = moveBall state player cpu
|
|
|
|
let next =
|
|
{ state with
|
|
Ball = ball
|
|
Vel = vel
|
|
Player = player
|
|
Cpu = cpu
|
|
Frame = n }
|
|
|
|
match scorer with
|
|
| Some side -> serve next side
|
|
| None -> next
|
|
|
|
let private setHeld (state: PongState) (key: Paddle) (value: bool) : PongState =
|
|
match key with
|
|
| Paddle.Up -> { state with Held = { state.Held with Up = value } }
|
|
| Paddle.Down -> { state with Held = { state.Held with Down = value } }
|
|
|
|
let pongUpdate (state: PongState) (action: PongAction) : PongState =
|
|
match action with
|
|
| PongAction.Press key -> setHeld state key true
|
|
| PongAction.Release key -> setHeld state key false
|
|
// A new match, but the keys are still where the player's fingers are.
|
|
| PongAction.Restart -> { initialPong state.Seed with Held = state.Held }
|
|
| PongAction.Frame -> frame state
|