feat(fsharp): m01-m06 util, model, rules, random, spawn, game, view with Expecto tests

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-29 00:49:42 +01:00
co-authored by Claude Opus 5.5
parent 183821737e
commit 9cbefeb2be
27 changed files with 851 additions and 6 deletions
+26
View File
@@ -0,0 +1,26 @@
/// m05 game: one `update` turns every action into a new state; replaying a game is a fold over its actions.
module Snake.Game
open Snake.Model
/// How many frames pass between game steps (m10): `max 2 (8 - score / 3)` (integer division).
/// Score 0-2 -> 8, 3-5 -> 7, ..., 18 and up -> 2.
let movesEvery (score: int) : int = failwith "TODO m10"
/// The one function that says how the game changes: `state -> action -> state`.
///
/// m05: `Tick` runs a game step (`Spawn.tick`), `Turn` asks to change direction (`Rules.turn`);
/// every other action returns the state unchanged.
///
/// (m10) `TogglePause` switches Playing <-> Paused and does nothing in any other status.
/// (m10) `Restart` gives `Spawn.newGame state.Seed`, whatever the status.
/// (m10) `Frame` does nothing unless Playing; otherwise it adds 1 to `Frame`, and when the new `Frame` is a multiple of
/// `movesEvery Score` it also runs a game step (`Spawn.tick`) on that incremented state.
/// In m10, match every case of `Action` explicitly (no `| _ ->`), so the compiler tells you if one is forgotten.
let update (state: GameState) (action: Action) : GameState = failwith "TODO m05"
/// Apply all actions in order, starting from `state`, and return the final state.
let replay (state: GameState) (actions: Action list) : GameState = failwith "TODO m05"
/// Like `replay`, but keep every state: `[state; afterFirst; afterSecond; ...]`, so its length is `List.length actions + 1`.
let history (state: GameState) (actions: Action list) : GameState list = failwith "TODO m05"
+48
View File
@@ -0,0 +1,48 @@
/// m02 model: the data the game is built from, plus two tiny functions over directions.
module Snake.Model
/// A grid cell. Records compare structurally: two points with the same X and Y are `=`.
type Point = { X: int; Y: int }
type Direction =
| Up
| Down
| Left
| Right
type Status =
| Playing
| Paused
| GameOver
| Won
/// Everything that can happen to the game, as data.
type Action =
| Tick
| Turn of Direction
| TogglePause
| Restart
| Frame
/// The whole game. `Snake` is head first; `Seed` is the unsigned 32-bit random seed.
type GameState =
{ Width: int
Height: int
Snake: Point list
Dir: Direction
NextDir: Direction
Apple: Point option
Seed: uint32
Score: int
Status: Status
Frame: int }
/// The unit step for a direction: Up (0,-1), Down (0,1), Left (-1,0), Right (1,0). Y grows downward.
let delta (dir: Direction) : Point = failwith "TODO m02"
/// The direction pointing the other way.
let opposite (dir: Direction) : Direction = failwith "TODO m02"
/// 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.
let initialState (seed: uint32) : GameState = failwith "TODO m02"
+30
View File
@@ -0,0 +1,30 @@
/// m04 random. A `Random<'a>` wraps a plain function from a seed to a pair: `seed -> (value, nextSeed)`.
/// Seeds and values are unsigned 32-bit integers (`uint32`).
/// Nothing is hidden or mutated: the caller carries the seed.
module Snake.Random
type Random<'a> = Random of (uint32 -> 'a * uint32)
/// Run `r` from `seed`: the value it produces and the seed it leaves behind.
let run (seed: uint32) (r: Random<'a>) : 'a * uint32 = failwith "TODO m04"
/// A Random that always gives `a` and leaves the seed untouched.
let pure (a: 'a) : Random<'a> = failwith "TODO m04"
/// Transform the value `r` produces; the seed threading is unchanged.
let map (f: 'a -> 'b) (r: Random<'a>) : Random<'b> = failwith "TODO m04"
/// Run `r`, feed its value to `f` to pick the next Random, and run that with the seed `r` left behind.
let bind (f: 'a -> Random<'b>) (r: Random<'a>) : Random<'b> = failwith "TODO m04"
/// The generator (mulberry32). uint32 arithmetic already wraps around at 2^32 and `>>>` on a uint32 is an
/// unsigned shift. With `s = seed + 0x6D2B79F5u`:
/// t = (s ^^^ (s >>> 15)) * (s ||| 1u)
/// t = t ^^^ (t + (t ^^^ (t >>> 7)) * (t ||| 61u))
/// value = t ^^^ (t >>> 14)
/// It returns `value` and the new seed `s`.
/// (The stub is wrapped in `Random` so the TODO only fires when the generator is run.)
let nextUint32: Random<uint32> = Random(fun _ -> failwith "TODO m04")
/// A Random giving a number in `[0, n)`: the `nextUint32` value modulo `n`.
let intBelow (n: int) : Random<int> = failwith "TODO m04"
+22
View File
@@ -0,0 +1,22 @@
/// m03 rules: the pure game rules. Each function takes a state and returns a new one.
module Snake.Rules
open Snake.Model
/// The cell one step from `p` in direction `dir`. No wrapping: it may land outside the board.
let move (p: Point) (dir: Direction) : Point = failwith "TODO m03"
/// True if `p` lies on the board: `0 <= X < Width` and `0 <= Y < Height`.
let inBounds (state: GameState) (p: Point) : bool = failwith "TODO m03"
/// 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.
let turn (state: GameState) (dir: Direction) : GameState = failwith "TODO m03"
/// 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).
let advance (state: GameState) : GameState = failwith "TODO m03"
+8
View File
@@ -1,9 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<!-- FS0046: `pure` is reserved for future use by F#; the course keeps the name used by every track. -->
<NoWarn>$(NoWarn);FS0046</NoWarn>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<Compile Include="Util.fs" />
<Compile Include="Model.fs" />
<Compile Include="Rules.fs" />
<Compile Include="Random.fs" />
<Compile Include="Spawn.fs" />
<Compile Include="Game.fs" />
<Compile Include="View.fs" />
</ItemGroup>
</Project>
+18
View File
@@ -0,0 +1,18 @@
/// m04 spawn: placing apples with the Random from Random.fs.
module Snake.Spawn
open Snake.Model
/// Every board cell not covered by the snake, row-major (y outer loop, x inner loop). The apple does not count as occupied.
let freeCells (state: GameState) : Point list = failwith "TODO m04"
/// Put an apple on a random free cell, drawing from `state.Seed` (use `Random.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).
let spawnApple (state: GameState) : GameState = failwith "TODO m04"
/// One game step: `Rules.advance`, then make sure there is an apple.
let tick (state: GameState) : GameState = failwith "TODO m04"
/// A fresh game (`initialState`) with its first apple already placed.
let newGame (seed: uint32) : GameState = failwith "TODO m04"
+11 -1
View File
@@ -1,2 +1,12 @@
/// Placeholder; replaced by the m01 utilities.
/// m01 util: small helpers used by every later module.
/// (The JavaScript track also writes `pipe` and `compose` here; F# has them built in as `|>` and `>>`.)
module Snake.Util
/// `range n` is `[0; 1; ...; n-1]`; `range 0` is `[]`.
let range (n: int) : int list = failwith "TODO m01"
/// `xs` without its last element; an empty list stays empty.
let dropLast (xs: 'a list) : 'a list = failwith "TODO m01"
/// True if some element `x` of `xs` satisfies `eq x p`. `eq` lets callers choose how elements are compared.
let contains (xs: 'a list) (p: 'a) (eq: 'a -> 'a -> bool) : bool = failwith "TODO m01"
+23
View File
@@ -0,0 +1,23 @@
/// m06 view: from game state to a Scene. The picture is plain data; drawing it is somebody else's job (m07).
module Snake.View
open Snake.Model
/// A thing to draw.
type Shape =
| Rect of x: int * y: int * w: int * h: int * color: string
| Text of x: int * y: int * text: string * color: string * size: int * align: string
/// A picture: shapes drawn in order.
type Scene = Shape list
/// Pixels per board cell.
let CELL = 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.
let view (state: GameState) : Scene = failwith "TODO m06"