feat(scala): m01–m06 port with shared fixtures
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
045029639e
commit
1e186733d1
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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] = ???
|
||||
@@ -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 = ???
|
||||
@@ -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] = ???
|
||||
@@ -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 = ???
|
||||
@@ -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 = ???
|
||||
@@ -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 = ???
|
||||
@@ -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 = ???
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user