diff --git a/scala/solutions/Game.scala b/scala/solutions/Game.scala new file mode 100644 index 0000000..cb43e6e --- /dev/null +++ b/scala/solutions/Game.scala @@ -0,0 +1,14 @@ +package snake + +/** m05 game: one `update` turns every action into a new state; replaying a game is a fold over its actions. */ +object Game: + + def update(state: GameState, action: Action): GameState = action match + case Action.Tick => Spawn.tick(state) + case Action.Turn(dir) => Rules.turn(state, dir) + case _ => state // TogglePause, Restart and Frame arrive in m10 + + def replay(state: GameState, actions: List[Action]): GameState = actions.foldLeft(state)(update) + + // scanLeft is foldLeft that keeps every intermediate result. + def history(state: GameState, actions: List[Action]): List[GameState] = actions.scanLeft(state)(update) diff --git a/scala/solutions/Model.scala b/scala/solutions/Model.scala new file mode 100644 index 0000000..691c249 --- /dev/null +++ b/scala/solutions/Model.scala @@ -0,0 +1,60 @@ +package snake + +/** A grid cell. Two points with the same `x` and `y` are `==` (case classes compare structurally). */ +final case class Point(x: Int, y: Int) + +enum Direction: + case Up, Down, Left, Right + +enum Status: + case Playing, Paused, GameOver, Won + +/** Everything that can happen to the game, as data. */ +enum Action: + case Tick + case Turn(dir: Direction) + case TogglePause + case Restart + case Frame + +/** The whole game. `snake` is head first; `seed` is the unsigned 32-bit random seed (0 until 2^32). */ +final case class GameState( + width: Int, + height: Int, + snake: List[Point], + dir: Direction, + nextDir: Direction, + apple: Option[Point], + seed: Long, + score: Int, + status: Status, + frame: Int +) + +/** m02 model: the data the whole game is built from. Case classes and enums are immutable. */ +object Model: + + def delta(dir: Direction): Point = dir match + case Direction.Up => Point(0, -1) + case Direction.Down => Point(0, 1) + case Direction.Left => Point(-1, 0) + case Direction.Right => Point(1, 0) + + def opposite(dir: Direction): Direction = dir match + case Direction.Up => Direction.Down + case Direction.Down => Direction.Up + case Direction.Left => Direction.Right + case Direction.Right => Direction.Left + + def initialState(seed: Long): GameState = GameState( + width = 20, + height = 20, + snake = List(Point(10, 10), Point(9, 10), Point(8, 10)), + dir = Direction.Right, + nextDir = Direction.Right, + apple = None, + seed = seed, + score = 0, + status = Status.Playing, + frame = 0 + ) diff --git a/scala/solutions/Random.scala b/scala/solutions/Random.scala new file mode 100644 index 0000000..7dd4a10 --- /dev/null +++ b/scala/solutions/Random.scala @@ -0,0 +1,36 @@ +package snake + +/** + * m04 random: a tiny "state monad" for randomness. A Random[A] is a function seed => (A, nextSeed), + * so every draw is a pure function and the seed is passed along explicitly instead of hidden. + * Seeds and values are unsigned 32-bit numbers kept in a Long. + */ +final case class Random[A](run: Long => (A, Long)): + + def map[B](f: A => B): Random[B] = + Random { seed => + val (a, next) = run(seed) + (f(a), next) + } + + def flatMap[B](f: A => Random[B]): Random[B] = + Random { seed => + val (a, next) = run(seed) + f(a).run(next) + } + +object Random: + + def pure[A](a: A): Random[A] = Random(seed => (a, seed)) + + // The mulberry32 arithmetic is done in Int: `*` wraps like 32-bit multiplication and `>>>` is unsigned. + // `& 0xFFFFFFFFL` turns the result back into an unsigned value held in a Long. + lazy val nextUint32: Random[Long] = Random { seed => + val s = seed.toInt + 0x6d2b79f5 + val t1 = (s ^ (s >>> 15)) * (s | 1) + val t2 = t1 ^ (t1 + (t1 ^ (t1 >>> 7)) * (t1 | 61)) + val value = t2 ^ (t2 >>> 14) + (value.toLong & 0xffffffffL, s.toLong & 0xffffffffL) + } + + def intBelow(n: Int): Random[Int] = nextUint32.map(v => (v % n).toInt) diff --git a/scala/solutions/Rules.scala b/scala/solutions/Rules.scala new file mode 100644 index 0000000..e2b07e9 --- /dev/null +++ b/scala/solutions/Rules.scala @@ -0,0 +1,34 @@ +package snake + +/** m03 rules: the pure game rules. Each function takes a state and returns a new one. */ +object Rules: + + def move(p: Point, dir: Direction): Point = + val d = Model.delta(dir) + Point(p.x + d.x, p.y + d.y) + + def inBounds(state: GameState, p: Point): Boolean = + p.x >= 0 && p.x < state.width && p.y >= 0 && p.y < state.height + + // Validate against `dir` (last moved), not `nextDir`: Up then Left within one tick must not fold the snake back on itself. + def turn(state: GameState, dir: Direction): GameState = + if state.status == Status.Playing && dir != Model.opposite(state.dir) then state.copy(nextDir = dir) + else state + + def advance(state: GameState): GameState = + if state.status != Status.Playing then state + else + val d = state.nextDir + val head = move(state.snake.head, d) + val eating = state.apple.contains(head) + // When not eating, the tail leaves its cell this step, so the head may move into it. + val body = if eating then state.snake else Util.dropLast(state.snake) + if !inBounds(state, head) || Util.contains(body, head, _ == _) then + state.copy(dir = d, status = Status.GameOver) + else + state.copy( + snake = head :: body, + dir = d, + apple = if eating then None else state.apple, + score = state.score + (if eating then 1 else 0) + ) diff --git a/scala/solutions/Spawn.scala b/scala/solutions/Spawn.scala new file mode 100644 index 0000000..78da631 --- /dev/null +++ b/scala/solutions/Spawn.scala @@ -0,0 +1,25 @@ +package snake + +/** m04 spawn: placing apples with the Random from Random.scala. */ +object Spawn: + + def freeCells(state: GameState): List[Point] = + for + y <- Util.range(state.height) + x <- Util.range(state.width) + p = Point(x, y) + if !Util.contains(state.snake, p, _ == _) + yield p + + def spawnApple(state: GameState): GameState = + if state.apple.isDefined || state.status != Status.Playing then state + else + val cells = freeCells(state) + if cells.isEmpty then state.copy(status = Status.Won) + else + val (i, seed) = Random.intBelow(cells.length).run(state.seed) + state.copy(apple = Some(cells(i)), seed = seed) + + def tick(state: GameState): GameState = spawnApple(Rules.advance(state)) + + def newGame(seed: Long): GameState = spawnApple(Model.initialState(seed)) diff --git a/scala/solutions/Util.scala b/scala/solutions/Util.scala new file mode 100644 index 0000000..e877584 --- /dev/null +++ b/scala/solutions/Util.scala @@ -0,0 +1,10 @@ +package snake + +/** m01 util: small helpers used by every later module. */ +object Util: + + def range(n: Int): List[Int] = List.range(0, n) + + def dropLast[A](xs: List[A]): List[A] = xs.dropRight(1) + + def contains[A](xs: List[A], p: A, eq: (A, A) => Boolean): Boolean = xs.exists(x => eq(x, p)) diff --git a/scala/solutions/View.scala b/scala/solutions/View.scala new file mode 100644 index 0000000..3807623 --- /dev/null +++ b/scala/solutions/View.scala @@ -0,0 +1,40 @@ +package snake + +/** A thing to draw. The picture is plain data; drawing it is somebody else's job (m07). */ +enum Shape: + case Rect(x: Int, y: Int, w: Int, h: Int, color: String) + case Text(x: Int, y: Int, text: String, color: String, size: Int, align: String) + +/** A picture: shapes drawn in order. */ +type Scene = List[Shape] + +/** m06 view: the picture is plain data (a list of shapes); drawing it is somebody else's job (m07). */ +object View: + + val CELL: Int = 20 + + private def rect(x: Int, y: Int, w: Int, h: Int, color: String): Shape = Shape.Rect(x, y, w, h, color) + + private def text(x: Int, y: Int, str: String, size: Int, align: String): Shape = + Shape.Text(x, y, str, "#ffffff", size, align) + + // One cell, inset by 1px so neighbouring segments show a gap. + private def cell(p: Point, color: String): Shape = rect(p.x * CELL + 1, p.y * CELL + 1, CELL - 2, CELL - 2, color) + + private def overlay(state: GameState): Scene = + val cx = state.width * CELL / 2 + val cy = state.height * CELL / 2 + def banner(title: String, hint: String): Scene = + List(text(cx, cy, title, 32, "center"), text(cx, cy + 30, hint, 16, "center")) + state.status match + case Status.Paused => banner("Paused", "Press Space to resume") + case Status.GameOver => banner("Game Over", "Press R to restart") + case Status.Won => banner("You Win!", "Press R to restart") + case Status.Playing => Nil + + def view(state: GameState): Scene = + List(rect(0, 0, state.width * CELL, state.height * CELL, "#111111")) ++ + state.apple.map(cell(_, "#e53935")).toList ++ + state.snake.zipWithIndex.map((p, i) => cell(p, if i == 0 then "#7cb342" else "#43a047")) ++ + List(text(6, 18, s"Score: ${state.score}", 16, "left")) ++ + overlay(state) diff --git a/scala/src/Game.scala b/scala/src/Game.scala new file mode 100644 index 0000000..39347a3 --- /dev/null +++ b/scala/src/Game.scala @@ -0,0 +1,18 @@ +package snake + +/** m05 game: one `update` turns every action into a new state; replaying a game is a fold over its actions. */ +object Game: + + /** + * The one function that says how the game changes: `(state, action) => state`. + * + * `Tick` runs a game step (`Spawn.tick`), `Turn` asks to change direction (`Rules.turn`); + * every other action returns the state unchanged (m10 gives them meaning). + */ + def update(state: GameState, action: Action): GameState = ??? + + /** Apply all actions in order, starting from `state`, and return the final state. */ + def replay(state: GameState, actions: List[Action]): GameState = ??? + + /** Like `replay`, but keep every state: `List(state, afterFirst, afterSecond, ...)`, so its length is `actions.length + 1`. */ + def history(state: GameState, actions: List[Action]): List[GameState] = ??? diff --git a/scala/src/Model.scala b/scala/src/Model.scala new file mode 100644 index 0000000..009e547 --- /dev/null +++ b/scala/src/Model.scala @@ -0,0 +1,47 @@ +package snake + +/** A grid cell. Two points with the same `x` and `y` are `==` (case classes compare structurally). */ +final case class Point(x: Int, y: Int) + +enum Direction: + case Up, Down, Left, Right + +enum Status: + case Playing, Paused, GameOver, Won + +/** Everything that can happen to the game, as data. */ +enum Action: + case Tick + case Turn(dir: Direction) + case TogglePause + case Restart + case Frame + +/** The whole game. `snake` is head first; `seed` is the unsigned 32-bit random seed (0 until 2^32). */ +final case class GameState( + width: Int, + height: Int, + snake: List[Point], + dir: Direction, + nextDir: Direction, + apple: Option[Point], + seed: Long, + score: Int, + status: Status, + frame: Int +) + +/** m02 model: the data the game is built from, plus two tiny functions over directions. */ +object Model: + + /** The unit step for a direction: Up (0,-1), Down (0,1), Left (-1,0), Right (1,0). y grows downward. */ + def delta(dir: Direction): Point = ??? + + /** The direction pointing the other way. */ + def opposite(dir: Direction): Direction = ??? + + /** + * A fresh game: 20x20, snake (10,10),(9,10),(8,10) heading Right (`dir` and `nextDir`), + * no apple, the given seed, score 0, status Playing, frame 0. + */ + def initialState(seed: Long): GameState = ??? diff --git a/scala/src/Random.scala b/scala/src/Random.scala new file mode 100644 index 0000000..888b3cd --- /dev/null +++ b/scala/src/Random.scala @@ -0,0 +1,34 @@ +package snake + +/** + * A `Random[A]` is a plain function from a seed to a pair: `seed => (value, nextSeed)`. + * Seeds and values are unsigned 32-bit integers held in a `Long` (0 until 2^32). + * Nothing is hidden or mutated: the caller carries the seed. + */ +final case class Random[A](run: Long => (A, Long)): + + /** Transform the value this Random produces; the seed threading is unchanged. */ + def map[B](f: A => B): Random[B] = ??? + + /** Run this Random, feed its value to `f` to pick the next Random, and run that with the seed this one left behind. */ + def flatMap[B](f: A => Random[B]): Random[B] = ??? + +object Random: + + /** A Random that always gives `a` and leaves the seed untouched. */ + def pure[A](a: A): Random[A] = ??? + + /** + * The generator (mulberry32). Do the arithmetic in `Int`: `*` wraps around like 32-bit multiplication + * and `>>>` is an unsigned shift. With `s = seed.toInt + 0x6D2B79F5`: + * {{{ + * t = (s ^ (s >>> 15)) * (s | 1) + * t = t ^ (t + (t ^ (t >>> 7)) * (t | 61)) + * value = t ^ (t >>> 14) + * }}} + * Both `value` and the new seed `s` are returned as unsigned values: `x.toLong & 0xFFFFFFFFL`. + */ + lazy val nextUint32: Random[Long] = ??? + + /** A Random giving a number in `[0, n)`: the `nextUint32` value modulo `n`. */ + def intBelow(n: Int): Random[Int] = ??? diff --git a/scala/src/Rules.scala b/scala/src/Rules.scala new file mode 100644 index 0000000..244525d --- /dev/null +++ b/scala/src/Rules.scala @@ -0,0 +1,26 @@ +package snake + +/** m03 rules: the pure game rules. Each function takes a state and returns a new one. */ +object Rules: + + /** The cell one step from `p` in direction `dir`. No wrapping: it may land outside the board. */ + def move(p: Point, dir: Direction): Point = ??? + + /** True if `p` lies on the board: `0 <= x < width` and `0 <= y < height`. */ + def inBounds(state: GameState, p: Point): Boolean = ??? + + /** + * Ask to turn the snake. Returns `state` unchanged unless the game is Playing and + * `dir` is not the opposite of `state.dir` (the direction the snake last *moved*, not `nextDir`). + * Otherwise sets `nextDir = dir`. Several turns in one tick: the last allowed one wins. + */ + def turn(state: GameState, dir: Direction): GameState = ??? + + /** + * One step of the game. Returns `state` unchanged unless Playing. Otherwise the head moves in `nextDir` + * (which becomes `dir`): + * - head lands on the apple: the snake grows (tail kept), score + 1, apple becomes None; + * - otherwise the tail cell is vacated, so the head may enter the cell the tail is leaving; + * - head off the board, or onto a remaining body cell: status GameOver, snake unchanged (but `dir` updated). + */ + def advance(state: GameState): GameState = ??? diff --git a/scala/src/Spawn.scala b/scala/src/Spawn.scala new file mode 100644 index 0000000..6c22442 --- /dev/null +++ b/scala/src/Spawn.scala @@ -0,0 +1,20 @@ +package snake + +/** m04 spawn: placing apples with the Random from Random.scala. */ +object Spawn: + + /** Every board cell not covered by the snake, row-major (y outer loop, x inner loop). The apple does not count as occupied. */ + def freeCells(state: GameState): List[Point] = ??? + + /** + * Put an apple on a random free cell, drawing from `state.seed` (use `intBelow` and store the new seed). + * Unchanged if there already is an apple or the status is not Playing. + * If no cell is free the board is full: status becomes Won (seed unchanged, no apple). + */ + def spawnApple(state: GameState): GameState = ??? + + /** One game step: `advance`, then make sure there is an apple. */ + def tick(state: GameState): GameState = ??? + + /** A fresh game (`initialState`) with its first apple already placed. */ + def newGame(seed: Long): GameState = ??? diff --git a/scala/src/Util.scala b/scala/src/Util.scala new file mode 100644 index 0000000..d4e3693 --- /dev/null +++ b/scala/src/Util.scala @@ -0,0 +1,13 @@ +package snake + +/** Small helpers used by every later module (m01). */ +object Util: + + /** `range(n)` is `List(0, 1, ..., n-1)`; `range(0)` is `Nil`. */ + def range(n: Int): List[Int] = ??? + + /** `xs` without its last element; an empty list stays empty. */ + def dropLast[A](xs: List[A]): List[A] = ??? + + /** True if some element `x` of `xs` satisfies `eq(x, p)`. `eq` lets callers choose how elements are compared. */ + def contains[A](xs: List[A], p: A, eq: (A, A) => Boolean): Boolean = ??? diff --git a/scala/src/View.scala b/scala/src/View.scala new file mode 100644 index 0000000..2b8d16e --- /dev/null +++ b/scala/src/View.scala @@ -0,0 +1,25 @@ +package snake + +/** A thing to draw. The picture is plain data; drawing it is somebody else's job (m07). */ +enum Shape: + case Rect(x: Int, y: Int, w: Int, h: Int, color: String) + case Text(x: Int, y: Int, text: String, color: String, size: Int, align: String) + +/** A picture: shapes drawn in order. */ +type Scene = List[Shape] + +/** m06 view: from game state to a Scene. */ +object View: + + /** Pixels per board cell. */ + val CELL: Int = 20 + + /** + * Describe the picture as data, in this order: background `Rect(0, 0, width*CELL, height*CELL, "#111111")`; + * the apple (if any) as `Rect(ax*CELL+1, ay*CELL+1, CELL-2, CELL-2, "#e53935")`; each snake segment in list order, + * same size, head "#7cb342" and body "#43a047"; `Text(6, 18, "Score: ", "#ffffff", 16, "left")`; + * then an overlay centred at (width*CELL/2, height*CELL/2), all "#ffffff", align "center": + * Paused: "Paused" (size 32) and, 30px lower, "Press Space to resume" (size 16); + * GameOver: "Game Over" + "Press R to restart"; Won: "You Win!" + "Press R to restart"; Playing: none. + */ + def view(state: GameState): Scene = ??? diff --git a/scala/test/Fixtures.test.scala b/scala/test/Fixtures.test.scala index fd74b37..48ad719 100644 --- a/scala/test/Fixtures.test.scala +++ b/scala/test/Fixtures.test.scala @@ -2,8 +2,7 @@ package snake import java.nio.file.{Files, Paths} -/** Loads shared fixtures from ../fixtures/.json. - * Typed decoders (point, direction, status, action, state, opt) arrive with the model (Task 11). */ +/** Loads shared fixtures from ../fixtures/.json and decodes them into the course's types. */ object Fixtures: private val dir = Paths.get(sys.props.getOrElse("user.dir", ".")).resolve("..").resolve("fixtures").normalize @@ -16,3 +15,38 @@ object Fixtures: /** Decode a nullable JSON value into an Option. */ def opt[A](v: ujson.Value)(f: ujson.Value => A): Option[A] = if v.isNull then None else Some(f(v)) + + def point(v: ujson.Value): Point = Point(v("x").num.toInt, v("y").num.toInt) + + def direction(v: ujson.Value): Direction = Direction.valueOf(v.str) + + def status(v: ujson.Value): Status = Status.valueOf(v.str) + + def action(v: ujson.Value): Action = v("tag").str match + case "Tick" => Action.Tick + case "Turn" => Action.Turn(direction(v("dir"))) + case "TogglePause" => Action.TogglePause + case "Restart" => Action.Restart + case "Frame" => Action.Frame + case other => throw new IllegalArgumentException(s"unknown action tag: $other") + + def gameState(v: ujson.Value): GameState = GameState( + width = v("width").num.toInt, + height = v("height").num.toInt, + snake = v("snake").arr.toList.map(point), + dir = direction(v("dir")), + nextDir = direction(v("nextDir")), + apple = opt(v("apple"))(point), + seed = v("seed").num.toLong, + score = v("score").num.toInt, + status = status(v("status")), + frame = v("frame").num.toInt + ) + + def shape(v: ujson.Value): Shape = v("tag").str match + case "Rect" => Shape.Rect(v("x").num.toInt, v("y").num.toInt, v("w").num.toInt, v("h").num.toInt, v("color").str) + case "Text" => + Shape.Text(v("x").num.toInt, v("y").num.toInt, v("text").str, v("color").str, v("size").num.toInt, v("align").str) + case other => throw new IllegalArgumentException(s"unknown shape tag: $other") + + def list[A](v: ujson.Value)(f: ujson.Value => A): List[A] = v.arr.toList.map(f) diff --git a/scala/test/M01Util.test.scala b/scala/test/M01Util.test.scala new file mode 100644 index 0000000..f51cbf2 --- /dev/null +++ b/scala/test/M01Util.test.scala @@ -0,0 +1,23 @@ +package snake + +class M01UtilSuite extends munit.FunSuite: + test("range(0) is empty") { assertEquals(Util.range(0), Nil) } + test("range(3) counts from zero up to n-1") { assertEquals(Util.range(3), List(0, 1, 2)) } + + test("dropLast of an empty list is empty") { assertEquals(Util.dropLast(List.empty[Int]), Nil) } + test("dropLast removes only the last element") { assertEquals(Util.dropLast(List(1, 2, 3)), List(1, 2)) } + test("dropLast leaves its input untouched") { + val xs = List(1, 2, 3) + assertEquals(Util.dropLast(xs), List(1, 2)) + assertEquals(xs, List(1, 2, 3)) + } + + private val sameParity = (a: Int, b: Int) => a % 2 == b % 2 + test("contains finds an element using the supplied equality") { + assertEquals(Util.contains(List(1, 2, 3), 5, sameParity), true) + } + test("contains is false when no element is equal") { assertEquals(Util.contains(List(2, 4), 3, sameParity), false) } + test("contains is false for an empty list") { assertEquals(Util.contains(List.empty[Int], 1, _ == _), false) } + test("contains compares structurally when given a structural equality") { + assertEquals(Util.contains(List(Point(1, 2)), Point(1, 2), _ == _), true) + } diff --git a/scala/test/M02Model.test.scala b/scala/test/M02Model.test.scala new file mode 100644 index 0000000..fe32d6d --- /dev/null +++ b/scala/test/M02Model.test.scala @@ -0,0 +1,54 @@ +package snake + +import Fixtures.* + +class M02ModelSuite extends munit.FunSuite: + cases("m02-delta").foreach { c => + test(s"delta: ${c("name").str}") { + assertEquals(Model.delta(direction(c("args")(0))), point(c("expected"))) + } + } + + cases("m02-opposite").foreach { c => + test(s"opposite: ${c("name").str}") { + assertEquals(Model.opposite(direction(c("args")(0))), direction(c("expected"))) + } + } + + // pointEquals has no Scala function: case classes already compare structurally with `==`. + // The shared fixture is kept so every track checks the same four situations. + cases("m02-point-equals").foreach { c => + test(s"pointEquals (case-class ==): ${c("name").str}") { + val a = point(c("args")(0)) + val b = point(c("args")(1)) + assertEquals(a == b, c("expected").bool) + } + } + + cases("m02-initial-state").foreach { c => + test(s"initialState: ${c("name").str}") { + assertEquals(Model.initialState(c("args")(0).num.toLong), gameState(c("expected"))) + } + } + + test("Point holds its coordinates") { + val p = Point(3, 4) + assertEquals((p.x, p.y), (3, 4)) + } + + test("opposite is its own inverse") { + Direction.values.foreach(d => assertEquals(Model.opposite(Model.opposite(d)), d)) + } + + test("Action.Turn carries the direction") { + assertEquals(Action.Turn(Direction.Left), Action.Turn(Direction.Left)) + assertNotEquals(Action.Turn(Direction.Left), Action.Turn(Direction.Right)) + Action.Turn(Direction.Left) match + case Action.Turn(d) => assertEquals(d, Direction.Left) + case other => fail(s"expected Turn, got $other") + } + + test("the other Actions are distinct constants") { + val all = List(Action.Tick, Action.TogglePause, Action.Restart, Action.Frame) + assertEquals(all.distinct.length, 4) + } diff --git a/scala/test/M03Rules.test.scala b/scala/test/M03Rules.test.scala new file mode 100644 index 0000000..6abbf8a --- /dev/null +++ b/scala/test/M03Rules.test.scala @@ -0,0 +1,31 @@ +package snake + +import Fixtures.* + +class M03RulesSuite extends munit.FunSuite: + cases("m03-move").foreach { c => + test(s"move: ${c("name").str}") { + val a = c("args") + assertEquals(Rules.move(point(a(0)), direction(a(1))), point(c("expected"))) + } + } + + cases("m03-in-bounds").foreach { c => + test(s"inBounds: ${c("name").str}") { + val a = c("args") + assertEquals(Rules.inBounds(gameState(a(0)), point(a(1))), c("expected").bool) + } + } + + cases("m03-turn").foreach { c => + test(s"turn: ${c("name").str}") { + val a = c("args") + assertEquals(Rules.turn(gameState(a(0)), direction(a(1))), gameState(c("expected"))) + } + } + + cases("m03-advance").foreach { c => + test(s"advance: ${c("name").str}") { + assertEquals(Rules.advance(gameState(c("args")(0))), gameState(c("expected"))) + } + } diff --git a/scala/test/M04Random.test.scala b/scala/test/M04Random.test.scala new file mode 100644 index 0000000..d984dea --- /dev/null +++ b/scala/test/M04Random.test.scala @@ -0,0 +1,71 @@ +package snake + +import Fixtures.* + +class M04RandomSuite extends munit.FunSuite: + // Same values as the JS and F# tracks: if mulberry32 drifts (signed math, missing unsigned conversion), these fail. + cases("m04-random").foreach { c => + test(s"nextUint32: ${c("name").str}") { + val e = c("expected").arr + assertEquals(Random.nextUint32.run(c("args")(0).num.toLong), (e(0).num.toLong, e(1).num.toLong)) + } + } + + cases("m04-int-below").foreach { c => + test(s"intBelow: ${c("name").str}") { + val a = c("args") + val e = c("expected").arr + assertEquals(Random.intBelow(a(0).num.toInt).run(a(1).num.toLong), (e(0).num.toInt, e(1).num.toLong)) + } + } + + test("the recorded steps chain: each step starts from the previous step's seed") { + val cs = cases("m04-random") + cs.zipWithIndex.foreach { (c, i) => + if !c("name").str.endsWith("-step-1") then + assertEquals(c("args")(0).num.toLong, cs(i - 1)("expected")(1).num.toLong) + } + } + + test("pure returns its value and leaves the seed alone") { + assertEquals(Random.pure("x").run(99L), ("x", 99L)) + } + + test("map applies f to the value and keeps the seed from the underlying step") { + val (v, s) = Random.nextUint32.run(5L) + assertEquals(Random.nextUint32.map(_ + 1).run(5L), (v + 1, s)) + } + + test("map with the identity function changes nothing") { + assertEquals(Random.nextUint32.map(identity).run(5L), Random.nextUint32.run(5L)) + } + + test("flatMap threads the seed through both steps") { + val (a, s1) = Random.nextUint32.run(5L) + val (b, s2) = Random.nextUint32.run(s1) + val both = Random.nextUint32.flatMap(x => Random.nextUint32.map(y => (x, y))) + assertEquals(both.run(5L), ((a, b), s2)) + } + + test("flatMap with pure on the left or right changes nothing") { + assertEquals( + Random.pure(3L).flatMap(x => Random.nextUint32.map(_ + x)).run(5L), + Random.nextUint32.map(_ + 3L).run(5L) + ) + assertEquals(Random.nextUint32.flatMap(Random.pure).run(5L), Random.nextUint32.run(5L)) + } + + test("intBelow always lands in [0, n)") { + def draws(n: Int, seed: Long, count: Int): List[Int] = + if count == 0 then Nil + else + val (v, s) = Random.intBelow(n).run(seed) + v :: draws(n, s, count - 1) + List(1, 2, 7, 400).foreach { n => + draws(n, 123L, 50).foreach(v => assert(v >= 0 && v < n, s"$v not in [0, $n)")) + } + } + + test("running a Random does not mutate anything: same seed, same answer") { + assertEquals(Random.nextUint32.run(8L), Random.nextUint32.run(8L)) + } diff --git a/scala/test/M04Spawn.test.scala b/scala/test/M04Spawn.test.scala new file mode 100644 index 0000000..2ba50f1 --- /dev/null +++ b/scala/test/M04Spawn.test.scala @@ -0,0 +1,52 @@ +package snake + +import Fixtures.* + +class M04SpawnSuite extends munit.FunSuite: + cases("m04-free-cells").foreach { c => + test(s"freeCells: ${c("name").str}") { + assertEquals(Spawn.freeCells(gameState(c("args")(0))), list(c("expected"))(point)) + } + } + + cases("m04-spawn").foreach { c => + test(s"spawnApple: ${c("name").str}") { + assertEquals(Spawn.spawnApple(gameState(c("args")(0))), gameState(c("expected"))) + } + } + + cases("m04-new-game").foreach { c => + test(s"newGame: ${c("name").str}") { + assertEquals(Spawn.newGame(c("args")(0).num.toLong), gameState(c("expected"))) + } + } + + cases("m04-tick").foreach { c => + test(s"tick: ${c("name").str}") { + assertEquals(Spawn.tick(gameState(c("args")(0))), gameState(c("expected"))) + } + } + + // 3x2 board, one-cell snake, about to eat the apple at (1,0) + private val eating = GameState(3, 2, List(Point(0, 0)), Direction.Right, Direction.Right, + Some(Point(1, 0)), 0L, 0, Status.Playing, 0) + + test("tick: eating the apple spawns a new one in the same step") { + // snake becomes (1,0),(0,0); free cells are (2,0),(0,1),(1,1),(2,1); seed 0 draws 1144304738 % 4 = 2 -> (1,1) + assertEquals( + Spawn.tick(eating), + eating.copy(snake = List(Point(1, 0), Point(0, 0)), apple = Some(Point(1, 1)), score = 1, seed = 1831565813L) + ) + } + + test("tick is spawnApple after advance") { + assertEquals(Spawn.tick(eating), Spawn.spawnApple(Rules.advance(eating))) + } + + test("tick leaves the seed alone when no apple needs spawning") { + assertEquals(Spawn.tick(eating.copy(apple = Some(Point(2, 1)))).seed, 0L) + } + + test("newGame is reproducible: same seed, same game") { + assertEquals(Spawn.newGame(42L), Spawn.newGame(42L)) + } diff --git a/scala/test/M05Game.test.scala b/scala/test/M05Game.test.scala new file mode 100644 index 0000000..5cdfb7a --- /dev/null +++ b/scala/test/M05Game.test.scala @@ -0,0 +1,39 @@ +package snake + +import Fixtures.* + +class M05GameSuite extends munit.FunSuite: + cases("m05-update").foreach { c => + test(s"update: ${c("name").str}") { + val a = c("args") + assertEquals(Game.update(gameState(a(0)), action(a(1))), gameState(c("expected"))) + } + } + + cases("m05-replay").foreach { c => + test(s"replay: ${c("name").str}") { + val a = c("args") + assertEquals(Game.replay(gameState(a(0)), list(a(1))(action)), gameState(c("expected"))) + } + } + + cases("m05-history").foreach { c => + test(s"history: ${c("name").str}") { + val a = c("args") + assertEquals(Game.history(gameState(a(0)), list(a(1))(action)), list(c("expected"))(gameState)) + } + } + + private val replayArgs = cases("m05-replay")(0)("args") + private val start = gameState(replayArgs(0)) + private val actions = list(replayArgs(1))(action) + + test("history has one more state than there are actions") { + assertEquals(Game.history(start, actions).length, actions.length + 1) + } + + test("history starts at the start state and ends where replay ends") { + val states = Game.history(start, actions) + assertEquals(states.head, start) + assertEquals(states.last, Game.replay(start, actions)) + } diff --git a/scala/test/M06View.test.scala b/scala/test/M06View.test.scala new file mode 100644 index 0000000..88587f0 --- /dev/null +++ b/scala/test/M06View.test.scala @@ -0,0 +1,12 @@ +package snake + +import Fixtures.* + +class M06ViewSuite extends munit.FunSuite: + test("CELL is 20 pixels") { assertEquals(View.CELL, 20) } + + cases("m06-view").foreach { c => + test(s"view: ${c("name").str}") { + assertEquals(View.view(gameState(c("args")(0))), list(c("expected"))(shape)) + } + } diff --git a/scala/test/Purity.test.scala b/scala/test/Purity.test.scala new file mode 100644 index 0000000..86011bf --- /dev/null +++ b/scala/test/Purity.test.scala @@ -0,0 +1,51 @@ +package snake + +import java.nio.file.{Files, Path, Paths} +import scala.jdk.CollectionConverters.* + +class PuritySuite extends munit.FunSuite: + private val srcDir = Paths.get(sys.props.getOrElse("user.dir", ".")).resolve("src") + + // Files that are allowed to touch the outside world (a future web/ directory lives outside src/). + private def excluded(file: Path): Boolean = file.getFileName.toString == "Runtime.scala" + + private val banned: List[(String, scala.util.matching.Regex, String)] = List( + ("var", """\bvar\b""".r, "var n = 0"), + ("mutable", """\bmutable\b""".r, "val b = scala.collection.mutable.ListBuffer[Int]()"), + ("while", """\bwhile\b""".r, "while n < 3 do n += 1"), + ("scala.util.Random", """\bscala\.util\.Random\b""".r, "val r = scala.util.Random.nextInt()"), + ("System.", """\bSystem\.""".r, "val t = System.currentTimeMillis()"), + ("println", """\bprintln\b""".r, "println(\"hi\")") + ) + + private def stripComments(code: String): String = + code.replaceAll("""(?s)/\*.*?\*/""", "").replaceAll("""(?m)//.*$""", "") + + private def violations(code: String): List[String] = + val clean = stripComments(code) + banned.collect { case (name, re, _) if re.findFirstIn(clean).isDefined => name } + + private def scalaFiles(dir: Path): List[Path] = + if !Files.exists(dir) then Nil + else Files.walk(dir).iterator.asScala.filter(f => f.toString.endsWith(".scala")).toList.sorted + + banned.foreach { (name, _, sample) => + test(s"purity scanner flags a known-bad sample using $name") { + assert(violations(sample).contains(name)) + } + } + + test("purity scanner ignores banned words inside comments") { + val code = "// var mutable println\n/* while (x) System.exit(1) */\nval a = 1 // scala.util.Random\n" + assertEquals(violations(code), Nil) + } + + test("purity scanner accepts ordinary pure code") { + assertEquals(violations("def f(xs: List[Int]): List[Int] = xs.map(_ + 1)"), Nil) + } + + scalaFiles(srcDir).filterNot(excluded).foreach { f => + test(s"purity: ${srcDir.relativize(f)} has no banned constructs") { + assertEquals(violations(Files.readString(f)), Nil) + } + }