feat(scala): m01–m06 port with shared fixtures

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-29 00:07:27 +01:00
co-authored by Claude Sonnet 5.5
parent 045029639e
commit 1e186733d1
23 changed files with 771 additions and 2 deletions
+14
View File
@@ -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)
+60
View File
@@ -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
)
+36
View File
@@ -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)
+34
View File
@@ -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)
)
+25
View File
@@ -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))
+10
View File
@@ -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))
+40
View File
@@ -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)
+18
View File
@@ -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] = ???
+47
View File
@@ -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 = ???
+34
View File
@@ -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] = ???
+26
View File
@@ -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 = ???
+20
View File
@@ -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 = ???
+13
View File
@@ -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 = ???
+25
View File
@@ -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: <n>", "#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 = ???
+36 -2
View File
@@ -2,8 +2,7 @@ package snake
import java.nio.file.{Files, Paths}
/** Loads shared fixtures from ../fixtures/<name>.json.
* Typed decoders (point, direction, status, action, state, opt) arrive with the model (Task 11). */
/** Loads shared fixtures from ../fixtures/<name>.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)
+23
View File
@@ -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)
}
+54
View File
@@ -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)
}
+31
View File
@@ -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")))
}
}
+71
View File
@@ -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))
}
+52
View File
@@ -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))
}
+39
View File
@@ -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))
}
+12
View File
@@ -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))
}
}
+51
View File
@@ -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)
}
}