├── .gitignore ├── .paket ├── paket.bootstrapper.exe └── paket.targets ├── Dockerfile ├── README.md ├── paket.dependencies ├── paket.lock ├── public └── rels │ ├── games.html │ ├── join.html │ ├── newgame.html │ └── play.html └── src ├── App.fsx ├── Hypermedia.fsx ├── TicTacToe.Dsls.fsx ├── TicTacToe.Instructions.fsx ├── TicTacToe.Interpreters.fsx └── TicTacToe.fsx /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # User-specific files 5 | *.suo 6 | *.user 7 | *.userosscache 8 | *.sln.docstates 9 | 10 | # User-specific files (MonoDevelop/Xamarin Studio) 11 | *.userprefs 12 | 13 | # Build results 14 | [Dd]ebug/ 15 | [Dd]ebugPublic/ 16 | [Rr]elease/ 17 | [Rr]eleases/ 18 | x64/ 19 | x86/ 20 | bld/ 21 | [Bb]in/ 22 | [Oo]bj/ 23 | [Ll]og/ 24 | 25 | # Visual Studio 2015 cache/options directory 26 | .vs/ 27 | # Uncomment if you have tasks that create the project's static files in wwwroot 28 | #wwwroot/ 29 | 30 | # MSTest test Results 31 | [Tt]est[Rr]esult*/ 32 | [Bb]uild[Ll]og.* 33 | 34 | # NUNIT 35 | *.VisualState.xml 36 | TestResult.xml 37 | 38 | # Build Results of an ATL Project 39 | [Dd]ebugPS/ 40 | [Rr]eleasePS/ 41 | dlldata.c 42 | 43 | # DNX 44 | project.lock.json 45 | artifacts/ 46 | 47 | *_i.c 48 | *_p.c 49 | *_i.h 50 | *.ilk 51 | *.meta 52 | *.obj 53 | *.pch 54 | *.pdb 55 | *.pgc 56 | *.pgd 57 | *.rsp 58 | *.sbr 59 | *.tlb 60 | *.tli 61 | *.tlh 62 | *.tmp 63 | *.tmp_proj 64 | *.log 65 | *.vspscc 66 | *.vssscc 67 | .builds 68 | *.pidb 69 | *.svclog 70 | *.scc 71 | 72 | # Chutzpah Test files 73 | _Chutzpah* 74 | 75 | # Visual C++ cache files 76 | ipch/ 77 | *.aps 78 | *.ncb 79 | *.opendb 80 | *.opensdf 81 | *.sdf 82 | *.cachefile 83 | *.VC.db 84 | *.VC.VC.opendb 85 | 86 | # Visual Studio profiler 87 | *.psess 88 | *.vsp 89 | *.vspx 90 | *.sap 91 | 92 | # TFS 2012 Local Workspace 93 | $tf/ 94 | 95 | # Guidance Automation Toolkit 96 | *.gpState 97 | 98 | # ReSharper is a .NET coding add-in 99 | _ReSharper*/ 100 | *.[Rr]e[Ss]harper 101 | *.DotSettings.user 102 | 103 | # JustCode is a .NET coding add-in 104 | .JustCode 105 | 106 | # TeamCity is a build add-in 107 | _TeamCity* 108 | 109 | # DotCover is a Code Coverage Tool 110 | *.dotCover 111 | 112 | # NCrunch 113 | _NCrunch_* 114 | .*crunch*.local.xml 115 | nCrunchTemp_* 116 | 117 | # MightyMoose 118 | *.mm.* 119 | AutoTest.Net/ 120 | 121 | # Web workbench (sass) 122 | .sass-cache/ 123 | 124 | # Installshield output folder 125 | [Ee]xpress/ 126 | 127 | # DocProject is a documentation generator add-in 128 | DocProject/buildhelp/ 129 | DocProject/Help/*.HxT 130 | DocProject/Help/*.HxC 131 | DocProject/Help/*.hhc 132 | DocProject/Help/*.hhk 133 | DocProject/Help/*.hhp 134 | DocProject/Help/Html2 135 | DocProject/Help/html 136 | 137 | # Click-Once directory 138 | publish/ 139 | 140 | # Publish Web Output 141 | *.[Pp]ublish.xml 142 | *.azurePubxml 143 | # TODO: Comment the next line if you want to checkin your web deploy settings 144 | # but database connection strings (with potential passwords) will be unencrypted 145 | *.pubxml 146 | *.publishproj 147 | 148 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 149 | # checkin your Azure Web App publish settings, but sensitive information contained 150 | # in these scripts will be unencrypted 151 | PublishScripts/ 152 | 153 | # NuGet Packages 154 | *.nupkg 155 | # The packages folder can be ignored because of Package Restore 156 | **/packages/* 157 | # except build/, which is used as an MSBuild target. 158 | !**/packages/build/ 159 | # Uncomment if necessary however generally it will be regenerated when needed 160 | #!**/packages/repositories.config 161 | # NuGet v3's project.json files produces more ignoreable files 162 | *.nuget.props 163 | *.nuget.targets 164 | 165 | # Microsoft Azure Build Output 166 | csx/ 167 | *.build.csdef 168 | 169 | # Microsoft Azure Emulator 170 | ecf/ 171 | rcf/ 172 | 173 | # Windows Store app package directories and files 174 | AppPackages/ 175 | BundleArtifacts/ 176 | Package.StoreAssociation.xml 177 | _pkginfo.txt 178 | 179 | # Visual Studio cache files 180 | # files ending in .cache can be ignored 181 | *.[Cc]ache 182 | # but keep track of directories ending in .cache 183 | !*.[Cc]ache/ 184 | 185 | # Others 186 | ClientBin/ 187 | ~$* 188 | *~ 189 | *.dbmdl 190 | *.dbproj.schemaview 191 | *.pfx 192 | *.publishsettings 193 | node_modules/ 194 | orleans.codegen.cs 195 | 196 | # Since there are multiple workflows, uncomment next line to ignore bower_components 197 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 198 | #bower_components/ 199 | 200 | # RIA/Silverlight projects 201 | Generated_Code/ 202 | 203 | # Backup & report files from converting an old project file 204 | # to a newer Visual Studio version. Backup files are not needed, 205 | # because we have git ;-) 206 | _UpgradeReport_Files/ 207 | Backup*/ 208 | UpgradeLog*.XML 209 | UpgradeLog*.htm 210 | 211 | # SQL Server files 212 | *.mdf 213 | *.ldf 214 | 215 | # Business Intelligence projects 216 | *.rdl.data 217 | *.bim.layout 218 | *.bim_*.settings 219 | 220 | # Microsoft Fakes 221 | FakesAssemblies/ 222 | 223 | # GhostDoc plugin setting file 224 | *.GhostDoc.xml 225 | 226 | # Node.js Tools for Visual Studio 227 | .ntvs_analysis.dat 228 | 229 | # Visual Studio 6 build log 230 | *.plg 231 | 232 | # Visual Studio 6 workspace options file 233 | *.opt 234 | 235 | # Visual Studio LightSwitch build output 236 | **/*.HTMLClient/GeneratedArtifacts 237 | **/*.DesktopClient/GeneratedArtifacts 238 | **/*.DesktopClient/ModelManifest.xml 239 | **/*.Server/GeneratedArtifacts 240 | **/*.Server/ModelManifest.xml 241 | _Pvt_Extensions 242 | 243 | # Paket dependency manager 244 | .paket/paket.exe 245 | paket-files/ 246 | 247 | # FAKE - F# Make 248 | .fake/ 249 | 250 | # JetBrains Rider 251 | .idea/ 252 | *.sln.iml -------------------------------------------------------------------------------- /.paket/paket.bootstrapper.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/battermann/tic-tac-toe-backend/3215d21226c5f4b137fdb3c5846dd6c6c67c1004/.paket/paket.bootstrapper.exe -------------------------------------------------------------------------------- /.paket/paket.targets: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | true 6 | 7 | true 8 | $(MSBuildThisFileDirectory) 9 | $(MSBuildThisFileDirectory)..\ 10 | /Library/Frameworks/Mono.framework/Commands/mono 11 | mono 12 | 13 | 14 | 15 | $(PaketRootPath)paket.exe 16 | $(PaketToolsPath)paket.exe 17 | $(PaketToolsPath)paket.bootstrapper.exe 18 | "$(PaketExePath)" 19 | $(MonoPath) --runtime=v4.0.30319 "$(PaketExePath)" 20 | "$(PaketBootStrapperExePath)" $(PaketBootStrapperCommandArgs) 21 | $(MonoPath) --runtime=v4.0.30319 $(PaketBootStrapperExePath) $(PaketBootStrapperCommandArgs) 22 | 23 | $(MSBuildProjectDirectory)\paket.references 24 | $(MSBuildStartupDirectory)\paket.references 25 | $(MSBuildProjectFullPath).paket.references 26 | $(PaketCommand) restore --references-files "$(PaketReferences)" 27 | $(PaketBootStrapperCommand) 28 | 29 | RestorePackages; $(BuildDependsOn); 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM fsharp/fsharp 2 | COPY . . 3 | RUN mono ./.paket/paket.bootstrapper.exe 4 | RUN mono ./.paket/paket.exe restore 5 | RUN mono ./.paket/paket.exe generate-include-scripts 6 | CMD fsharpi --exec src/App.fsx $PORT -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tic-Tac-Toe Backend 2 | 3 | ## Create Docker image 4 | 5 | ```bash 6 | docker build -t tictactoe . 7 | ``` 8 | 9 | ## Run Docker 10 | 11 | ```bash 12 | docker run -d -p 5000:5000 -e PORT=5000 tictactoe 13 | ``` -------------------------------------------------------------------------------- /paket.dependencies: -------------------------------------------------------------------------------- 1 | source http://nuget.org/api/v2 2 | framework: >= net40 3 | nuget Chessie = 0.6.0 4 | nuget Suave = 2.0.2 5 | nuget FSharp.Data = 2.3.2 -------------------------------------------------------------------------------- /paket.lock: -------------------------------------------------------------------------------- 1 | FRAMEWORK: >= NET40 2 | NUGET 3 | remote: http://www.nuget.org/api/v2 4 | Chessie (0.6) 5 | FSharp.Core 6 | FSharp.Core (4.0.0.1) 7 | FSharp.Data (2.3.2) 8 | Suave (2.0.2) 9 | FSharp.Core (>= 4.0.0.1) 10 | -------------------------------------------------------------------------------- /public/rels/games.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Show all games 5 | 6 | 7 |

Show all games

8 |

A list of all the games.

9 |

URL

10 |

/games

11 |

Request

12 |

Method

13 |

14 |

URL parameters

15 |

none

16 |

Content Type

17 |

any

18 |

Data parameters

19 |

none

20 |

Example

21 |

22 |

Response

23 |

The response contains a list of games.

24 |

Response code

25 |

202 Accpeted

26 |

Content Type

27 |

application/hal+json

28 |

Example

29 |

{
30 |    "_embedded":{
31 |       "http://localhost:8000/rels/games":[
32 |          {
33 |             "_links":{
34 |                "http://localhost:8000/rels/join":{
35 |                   "href":"http://localhost:8000/games/06cee5ca-3ba5-4c9c-8d00-ac51e0d0f8c8/join"
36 |                },
37 |                "self":{
38 |                   "href":"http://localhost:8000/games/06cee5ca-3ba5-4c9c-8d00-ac51e0d0f8c8"
39 |                }
40 |             },
41 |             "id":"06cee5ca-3ba5-4c9c-8d00-ac51e0d0f8c8",
42 |             "status":"running"
43 |          },
44 |          {
45 |             "_links":{
46 |                "http://localhost:8000/rels/join":{
47 |                   "href":"http://localhost:8000/games/960005e1-0904-4eb9-850b-fbdcbd263e5e/join"
48 |                },
49 |                "self":{
50 |                   "href":"http://localhost:8000/games/960005e1-0904-4eb9-850b-fbdcbd263e5e"
51 |                }
52 |             },
53 |             "id":"960005e1-0904-4eb9-850b-fbdcbd263e5e",
54 |             "status":"running"
55 |          }
56 |       ]
57 |    },
58 |    "_links":{
59 |       "http://localhost:8000/rels/newgame":{
60 |          "href":"http://localhost:8000/games"
61 |       },
62 |       "self":{
63 |          "href":"http://localhost:8000/games"
64 |       }
65 |    }
66 | }

67 |

Errors

68 |

500 Interal Server Error

69 | 70 | -------------------------------------------------------------------------------- /public/rels/join.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Join a new game 4 | 5 | 6 |

Join a new game

7 |

Join a game that has been started by someone else. The player that joins will be player O. Games can only be joined if no one else has joined them before.

8 |

URL

9 |

/games/{id}/join

10 |

Request

11 |

Method

12 |

13 |

URL parameters

14 |

none

15 |

Content Type

16 |

any

17 |

Data parameters

18 |

none

19 |

Example

20 |

21 |

Response

22 |

The response contains the player ID for player O. In subsequent request on the game (to make plays) this ID has to be transmitted in the request body. The URL of the game can be found in the Location Header.

23 |

Response code

24 |

202 Accpeted

25 |

Content Type

26 |

application/hal+json

27 |

Example

28 |

Location http://localhost:8000/games/94a26c4a-75f7-45b2-8e20-a39f0f8818e8
29 | 
30 | { 
31 |     "playerId": "6acd905b-044e-47f8-9692-4b68d0c6ee07" 
32 | }

33 |

Errors

34 |

409 Conflict, 404 Not Found, 500 Internal Server Error

35 | 36 | -------------------------------------------------------------------------------- /public/rels/newgame.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Start a new game 5 | 6 | 7 |

Start a new game

8 |

Start a new game. The game will be created and appended to the game list. The game is now open for player O to join.

9 |

URL

10 |

/games

11 |

Request

12 |

Method

13 |

14 |

URL parameters

15 |

none

16 |

Content Type

17 |

any

18 |

Data parameters

19 |

none

20 |

Example

21 |

22 |

Response

23 |

The response contains the player ID for player X. In subsequent request on the game (to make plays) this ID has to be transmitted in the request body. The request is processed asynchronously. Therefore the response code is 202 Accepted. The URL of the new game is provided by the Location Header.

24 |

Response code

25 |

202 Accpeted

26 |

Content Type

27 |

application/hal+json

28 |

Example

29 |

Location http://localhost:8000/games/3f81eac5-26fc-4242-9444-126f785c43f6
30 | 
31 | { 
32 |     "playerId": "6acd905b-044e-47f8-9692-4b68d0c6ee07" 
33 | }

34 |

Errors

35 |

36 | 37 | -------------------------------------------------------------------------------- /public/rels/play.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Make a play 4 | 5 | 6 |

Make a play

7 |

This is the relation for making a play. The request has to provide the player ID and a description of the move in the request body. The move has a vertical and a horizontal position. Valid vertical positions are: "top", "vcenter", "bottom". Valid horizontal positions are: "left", "hcenter", "right".

8 |

URL

9 |

/games/{id}/moves

10 |

Request

11 |

Method

12 |

13 |

URL parameters

14 |

none

15 |

Content Type

16 |

application/json

17 |

Data parameters

18 |

vertical : string, horizontal : string, playerId : string

19 |

Example

20 |

{
21 |     "vertical": "top",
22 |     "horizontal": "left",
23 |     "playerId": "47769197-0ca6-414c-a018-610896eb645b"
24 | }

25 |

Response

26 |

The request will be processed asynchronously. Therefore the response is empty. To get the current state of the game a new request to the game URL has to made.

27 |

Response code

28 |

202 Accpeted

29 |

Content Type

30 |

31 |

Example

32 |

33 |

Errors

34 |

409 Conflict, 404 Not Found, 400 Bad Request

35 | 36 | -------------------------------------------------------------------------------- /src/App.fsx: -------------------------------------------------------------------------------- 1 | #load "TicTacToe.Interpreters.fsx" 2 | #load "Hypermedia.fsx" 3 | #I "../packages" 4 | #r "Suave/lib/net40/Suave.dll" 5 | 6 | open System 7 | open System.Net 8 | 9 | open FSharp.Data 10 | 11 | open Suave.Web 12 | open Suave.Successful 13 | open Suave.Operators 14 | open Suave.Http 15 | open Suave.ServerErrors 16 | open System.IO 17 | open Suave 18 | open Suave.Filters 19 | open Suave.RequestErrors 20 | 21 | open Chessie.ErrorHandling 22 | 23 | open Hypermedia 24 | open Hal 25 | open FSharpDataIntepreter 26 | 27 | open TicTacToe 28 | open Dsls.TicTacToeDsl 29 | open Dsls.Free 30 | open Instructions 31 | open Types 32 | open Interpreters 33 | open Effects 34 | 35 | let () path1 path2 = Path.Combine(path1, path2) 36 | 37 | let GAMES = "games" 38 | let GAME = "game" 39 | let MOVES = "moves" 40 | let JOIN = "join" 41 | let PLAY = "play" 42 | let NEWGAME = "newgame" 43 | 44 | 45 | module Paths = 46 | let api = "api" 47 | let games = api GAMES 48 | let game id = (api GAMES) id 49 | let moves id = game id MOVES 50 | let join id = game id JOIN 51 | let rels = sprintf "docs/rels/%s" 52 | 53 | module Routes = 54 | let game = new PrintfFormat<(string -> string),unit,string,string,string>(("/" Paths.games) "%s") 55 | let moves = new PrintfFormat<(string -> string),unit,string,string,string>((("/" Paths.games) "%s") MOVES) 56 | let join = new PrintfFormat<(string -> string),unit,string,string,string>((("/" Paths.games) "%s") JOIN) 57 | 58 | let gamesRel host = host ("games" |> Paths.rels) 59 | let joinRel host = host ("join" |> Paths.rels) 60 | let playRel host = host ("play" |> Paths.rels) 61 | let newGameRel host = host ("newgame" |> Paths.rels) 62 | 63 | type PlayerId = PlayerId of Guid 64 | 65 | type Players = { 66 | x: PlayerId option 67 | o: PlayerId option 68 | } 69 | 70 | type PlayerMapMessage = 71 | | Add of GameId * Players 72 | | TryJoin of GameId * PlayerId * AsyncReplyChannel> 73 | | TryFind of GameId * AsyncReplyChannel 74 | 75 | [] 76 | module Responses = 77 | type Error = { 78 | error: string 79 | } 80 | with 81 | static member ToJson (x:Error) = 82 | JsonValue.Record [| "error", JsonValue.String(x.error) |] 83 | 84 | 85 | [] 86 | module Requests = 87 | let findString s arr = arr |> Array.find (fun (k,v) -> k = s) |> snd |> fun v -> v.ToString() 88 | 89 | type Play = { 90 | vertical: string 91 | horizontal: string 92 | playerId: string 93 | } 94 | with 95 | static member FromJson (x:JsonValue) = 96 | match x with 97 | | JsonValue.Record props -> 98 | { vertical = props |> findString "vertical" 99 | horizontal = props |> findString "horizontal" 100 | playerId = props |> findString "playerId" } 101 | | _ -> failwith "bad format" 102 | 103 | type Player = { 104 | playerId: string 105 | } 106 | with 107 | static member FromJson (x:JsonValue) = 108 | match x with 109 | | JsonValue.Record props -> 110 | { playerId = props |> findString "playerId" } 111 | | _ -> failwith "bad format" 112 | 113 | static member ToJson (x:Player) = 114 | JsonValue.Record [| "playerId", JsonValue.String(x.playerId) |] 115 | 116 | [] 117 | module Serialization = 118 | let serializeList (list: string list) : JsonValue = 119 | list |> List.map JsonValue.String |> List.toArray |> JsonValue.Array 120 | 121 | let serializeGrid (grid: string list list) : JsonValue = 122 | grid |> List.map serializeList |> List.toArray |> JsonValue.Array 123 | 124 | [] 125 | module Mappers = 126 | let jsonToString (json: JsonValue) = json.ToString(JsonSaveOptions.DisableFormatting) 127 | 128 | let toGameList url (rms: (Dsls.ReadModel.GameListItemRm * bool) list) = 129 | let toListItem (rm: Dsls.ReadModel.GameListItemRm, closed) = { 130 | Resource.empty with 131 | Resource.links = Map.ofList [ yield "self", Singleton (Link.create (Uri (url Paths.game rm.id))) 132 | if not closed then yield (joinRel url, Singleton (Link.create (Uri (url Paths.join rm.id)))) ] 133 | properties = Map.ofList [ "id", JObject <| JsonValue.String(rm.id) 134 | "status", JObject <| JsonValue.String(rm.status) ] 135 | } 136 | { 137 | Resource.empty with 138 | Resource.links = Map.ofList [ "self", Link.create (Uri (url Paths.games)) |> Singleton 139 | newGameRel url, Link.create (Uri (url Paths.games)) |> Singleton ] 140 | embedded = Map.ofList [ gamesRel url, rms |> List.map toListItem |> Collection ] 141 | } 142 | 143 | let toGameResponse url closedForJoin (rm: Dsls.ReadModel.GameRm) = 144 | { 145 | Resource.empty with 146 | Resource.links = Map.ofList [ yield "self", Singleton (Link.create (Uri (url Paths.game rm.id))) 147 | yield "collection", Singleton (Link.create (Uri (url Paths.games))) 148 | yield playRel url, Singleton (Link.create (Uri (url Paths.moves rm.id))) 149 | if not closedForJoin then yield (joinRel url, Singleton (Link.create (Uri (url Paths.join rm.id)) )) ] 150 | properties = Map.ofList [ "status", JObject <| JsonValue.String(rm.status) 151 | "id", JObject <| JsonValue.String(rm.id) 152 | "grid", JObject <| serializeGrid rm.grid] 153 | } 154 | 155 | [] 156 | module Deserialization = 157 | let getPlay (req : HttpRequest) = 158 | let getString rawForm = Text.Encoding.UTF8.GetString(rawForm) 159 | req.rawForm 160 | |> getString 161 | |> JsonValue.Parse 162 | |> Play.FromJson 163 | 164 | let bothPlayersJoined (playerMap: Actor) gameId = 165 | async { 166 | let! players = playerMap.PostAndAsyncReply(fun rc -> TryFind (gameId, rc)) 167 | match players with 168 | | Some { x = Some _ ; o = Some _ } -> return true 169 | | _ -> return false 170 | } 171 | 172 | let game interpret (playerMap: Actor) (id: string) baseUrl: WebPart = 173 | let gameId = Guid(id) |> GameId 174 | fun (ctx: HttpContext) -> 175 | async { 176 | let! rm = interpret (Queries.game(gameId)) |> Async.ofAsyncResult 177 | let! closedForJoin = bothPlayersJoined playerMap gameId 178 | return! 179 | match rm with 180 | | Ok (v,_) -> 181 | OK (v |> toGameResponse baseUrl closedForJoin |> FSharpDataIntepreter.Hal.toJson |> jsonToString) ctx 182 | | Bad errs -> 183 | INTERNAL_ERROR ({ error = (errs |> String.concat ", ") } |> (Error.ToJson >> jsonToString)) ctx 184 | } 185 | 186 | let gamesWithJoinableFlag (interpret: Free<_> -> Effect<_>) (playerMap: Actor) = 187 | asyncTrial { 188 | let! games = interpret (Queries.games) 189 | let! gamesWithFlag = 190 | games |> List.map (fun (g: Dsls.ReadModel.GameListItemRm) -> bothPlayersJoined playerMap (Guid(g.id) |> GameId) |> Async.map (fun b -> g,b)) 191 | |> Async.Parallel 192 | return gamesWithFlag |> Array.toList 193 | } 194 | 195 | let games interpret (playerMap: Actor) baseUrl: WebPart = 196 | fun (ctx: HttpContext) -> 197 | async { 198 | let! rmWithJoinableFlag = gamesWithJoinableFlag interpret playerMap |> Async.ofAsyncResult 199 | return! 200 | match rmWithJoinableFlag with 201 | | Ok (v,_) -> 202 | OK (v |> toGameList baseUrl |> FSharpDataIntepreter.Hal.toJson |> jsonToString) ctx 203 | | Bad errs -> 204 | INTERNAL_ERROR ({ error = (errs |> String.concat ", ") } |> Error.ToJson |> jsonToString) ctx 205 | } 206 | 207 | let start interpret (playerMap: Actor) baseUrl: WebPart = 208 | let gameId = Guid.NewGuid() 209 | let playerId = Guid.NewGuid() 210 | do playerMap.Post(Add(GameId gameId, { x = PlayerId playerId |> Some; o = None })) 211 | // handle cmd asynchronously 212 | do interpret (Commands.handle(GameId gameId, Start)) |> Async.ofAsyncResult|> Async.map ignore |> Async.Start 213 | let body = { playerId = playerId.ToString() } |> Player.ToJson |> jsonToString 214 | ACCEPTED body >=> Writers.setHeader "Location" (baseUrl Paths.game (gameId.ToString())) 215 | 216 | let join (playerMap: Actor) (gameId: string) baseUrl: WebPart = 217 | let playerId = Guid.NewGuid() 218 | fun (ctx: HttpContext) -> 219 | async { 220 | let! result = playerMap.PostAndAsyncReply(fun rc -> TryJoin (Guid(gameId) |> GameId, playerId |> PlayerId, rc)) 221 | match result with 222 | | Ok _ -> 223 | let body = { playerId = playerId.ToString() } |> Player.ToJson |> jsonToString 224 | return! (ACCEPTED body >=> Writers.setHeader "Location" (baseUrl Paths.game (gameId.ToString()))) ctx 225 | | Bad errs -> 226 | return! 227 | if errs |> List.exists ((=) "game already running") then 228 | CONFLICT ({ error = (errs |> String.concat ", ") } |> Error.ToJson |> jsonToString) ctx 229 | elif errs |> List.exists ((=) "game invalid") then 230 | NOT_FOUND ({ error = (errs |> String.concat ", ") } |> Error.ToJson |> jsonToString) ctx 231 | else 232 | INTERNAL_ERROR ({ error = (errs |> String.concat ", ")} |> Error.ToJson |> jsonToString) ctx 233 | 234 | } 235 | 236 | let play interpret (playerMap: Actor) (id: string) (play:Play) baseUrl: WebPart = 237 | let toPosition (v: string, h: string) = 238 | match (v.ToLower(),h.ToLower()) with 239 | | "top", "left" -> (Top, Left) |> Some 240 | | "top", "hcenter" -> (Top, HCenter) |> Some 241 | | "top", "right" -> (Top, Right) |> Some 242 | | "vcenter", "left" -> (VCenter, Left) |> Some 243 | | "vcenter", "hcenter" -> (VCenter, HCenter) |> Some 244 | | "vcenter", "right" -> (VCenter, Right) |> Some 245 | | "bottom", "left" -> (Bottom, Left) |> Some 246 | | "bottom", "hcenter" -> (Bottom, HCenter) |> Some 247 | | "bottom", "right" -> (Bottom, Right) |> Some 248 | | _ -> None 249 | 250 | let maybePosition = toPosition (play.vertical, play.horizontal) 251 | let gameId = Guid(id) |> GameId 252 | fun (ctx: HttpContext) -> 253 | async { 254 | let! players = playerMap.PostAndAsyncReply(fun rc -> TryFind (gameId, rc)) 255 | let! closedForJoin = bothPlayersJoined playerMap gameId 256 | match players, maybePosition, closedForJoin with 257 | | Some { x = Some plX; o = Some plO }, Some(v,h), true -> 258 | let cmd = if (Guid(play.playerId) |> PlayerId) = plX then PlayX else PlayO 259 | // handle cmd asyncronously 260 | do interpret (Commands.handle(gameId, cmd (v, h))) |> Async.ofAsyncResult|> Async.map ignore |> Async.Start 261 | return! (ACCEPTED "{}" >=> Writers.setHeader "Location" (baseUrl Paths.game (gameId.ToString()))) ctx 262 | | Some _, Some _, false -> return! CONFLICT ({ error = "opponent hasn't joined game yet" } |> Error.ToJson |> jsonToString) ctx 263 | | _, None, _ -> return! BAD_REQUEST ({ error = "unknown position" } |> Error.ToJson |> jsonToString) ctx 264 | | _ -> return! NOT_FOUND ({ error = "unknown player id" } |> Error.ToJson |> jsonToString) ctx 265 | } 266 | 267 | let playersMapActor = 268 | Actor.Start(fun inbox -> 269 | let rec loop (playersMap: Map) = 270 | async { 271 | let! msg = inbox.Receive() 272 | match msg with 273 | | Add (gameId, players) -> 274 | return! loop (playersMap.Add(gameId, players)) 275 | | TryJoin (gameId, player, rc) -> 276 | match playersMap.TryFind gameId with 277 | | Some { x = Some _; o = Some _ } -> 278 | rc.Reply(fail "game already running") 279 | return! loop playersMap 280 | | Some { x = Some pX; o = None } -> 281 | rc.Reply(ok ()) 282 | return! loop (playersMap.Add(gameId, { x = Some pX; o = player |> Some })) 283 | | _ -> 284 | rc.Reply(fail "game invalid") 285 | return! loop playersMap 286 | | TryFind (gameId, rc) -> 287 | rc.Reply(playersMap.TryFind gameId) 288 | return! loop playersMap 289 | } 290 | loop Map.empty) 291 | 292 | let interpret free = 293 | TicTacToe.interpret 294 | Domain.interpret 295 | EventBus.interpret 296 | EventStore.interpret 297 | ReadModel.interpret free 298 | 299 | let app = 300 | let urlWithHost (request : HttpRequest) = 301 | let host = 302 | request.headers 303 | |> List.find (fst >> (=) "host") 304 | |> snd 305 | sprintf "%s://%s" request.url.Scheme host 306 | 307 | let setJsonHeader = Writers.setMimeType "application/hal+json" 308 | 309 | let setCorsHaeders = 310 | Writers.setHeader "Access-Control-Allow-Origin" "*" 311 | >=> Writers.setHeader "Access-Control-Allow-Headers" "content-type" 312 | >=> Writers.setHeader "Access-Control-Expose-Headers" "content-type, location" 313 | >=> Writers.setHeader "Access-Control-Allow-Methods" "POST, GET, OPTIONS, DELETE, PATCH" 314 | 315 | let setHeaders = setJsonHeader >=> setCorsHaeders 316 | 317 | choose [ 318 | GET >=> choose [ 319 | path ("/" Paths.api) >=> request (urlWithHost >> fun host -> 320 | { Resource.empty with links = Map.ofList [ "self", Singleton (Link.create (Uri (host Paths.api))) 321 | gamesRel host, Singleton (Link.create (Uri (host Paths.games))) ] } 322 | |> FSharpDataIntepreter.Hal.toJson|> jsonToString |> OK) 323 | >=> setHeaders 324 | path ("/" Paths.games) >=> request (urlWithHost >> games interpret playersMapActor) >=> setHeaders 325 | pathScan Routes.game (fun gameId -> 326 | request (urlWithHost >> game interpret playersMapActor gameId)) >=> setHeaders 327 | path ("/" Paths.rels GAMES) >=> Files.file "./public/rels/games.html" >=> setCorsHaeders 328 | path ("/" Paths.rels NEWGAME) >=> Files.file "./public/rels/newgame.html" >=> setCorsHaeders 329 | path ("/" Paths.rels JOIN) >=> Files.file "./public/rels/join.html" >=> setCorsHaeders 330 | path ("/" Paths.rels PLAY) >=> Files.file "./public/rels/play.html" >=> setCorsHaeders 331 | ] 332 | POST >=> choose [ 333 | path ("/" Paths.games) >=> request (urlWithHost >> start interpret playersMapActor) >=> setHeaders 334 | pathScan Routes.join (fun gameId -> 335 | request (urlWithHost >> join playersMapActor gameId)) >=> setHeaders 336 | pathScan Routes.moves (fun gameId -> 337 | request (fun req -> play interpret playersMapActor gameId (getPlay req) (urlWithHost req))) 338 | >=> setHeaders 339 | ] 340 | ] 341 | 342 | let config = 343 | let ip = IPAddress.Parse "0.0.0.0" 344 | let [|_; port|] = fsi.CommandLineArgs 345 | { defaultConfig with 346 | //logger = Logging.LoggerEx .saneDefaultsFor Logging.LogLevel.Info 347 | bindings= [ HttpBinding.create HTTP ip (uint16 port) ] } 348 | 349 | interpret (ReadModel.subscribe()) 350 | 351 | startWebServer config app 352 | -------------------------------------------------------------------------------- /src/Hypermedia.fsx: -------------------------------------------------------------------------------- 1 | module Hypermedia 2 | 3 | #load "../paket-files/include-scripts/net40/include.fsharp.data.fsx" 4 | 5 | let merge (maps: Map<_,_> seq): Map<_,_> = 6 | List.concat (maps |> Seq.map Map.toList) |> Map.ofList 7 | 8 | /// Represents a minimal generic Json object to describe a hypermedia resource. 9 | type JsonModel<'a> = 10 | | JObject of 'a 11 | | JRecord of Map> 12 | | JString of string 13 | | JArray of JsonModel<'a> list 14 | | JBool of bool 15 | 16 | /// Define Hypertext Application Language (HAL) resources. 17 | /// Transform HAL resource to specific Json implementations. 18 | module Hal = 19 | 20 | // todo ensure that _links and _embedded are unique 21 | 22 | open System 23 | open System.Reflection 24 | open Microsoft.FSharp.Reflection 25 | 26 | /// A link representation according to the HAL specification (https://tools.ietf.org/html/draft-kelly-json-hal-08). 27 | type Link = { 28 | href: Uri 29 | templated: bool option 30 | mediaType: string option 31 | deprication: Uri option 32 | name: string option 33 | profile: Uri option 34 | title: string option 35 | hreflang: string option 36 | } 37 | 38 | type Curies = Map 39 | 40 | type MaybeSingleton<'a> = 41 | | Singleton of 'a 42 | | Collection of 'a list 43 | 44 | module MaybeSingleton = 45 | let map f maybeSingleton = 46 | match maybeSingleton with 47 | | Singleton x -> Singleton (f x) 48 | | Collection xs -> Collection (xs |> List.map f) 49 | 50 | /// A resource representation according to the HAL specification (https://tools.ietf.org/html/draft-kelly-json-hal-08). 51 | type Resource<'a> = { 52 | curies: Curies 53 | links: Map> 54 | embedded: Map>> 55 | properties: Map> 56 | payload: obj option 57 | } 58 | 59 | [] 60 | [] 61 | module internal Curies = 62 | let internal tryFindName (curies: Curies) (relation: string): string option = 63 | let tryCreateUri str = 64 | match Uri.TryCreate(str, UriKind.Absolute) with 65 | | true, uri -> Some uri 66 | | _ -> None 67 | 68 | let matchUriWithTemplate (name: string, template: Uri) (uri: Uri): string option = 69 | if template.Segments |> Array.length <> (uri.Segments |> Array.length) then 70 | None 71 | else 72 | Array.zip template.Segments uri.Segments 73 | |> Array.tryFind (fst >> ((=) "%7Brel%7D")) 74 | |> Option.map (snd >> sprintf "%s:%s" name) 75 | 76 | curies 77 | |> Map.toSeq 78 | |> Seq.map matchUriWithTemplate 79 | |> Seq.choose (fun tryMatch -> tryCreateUri relation |> Option.bind tryMatch) 80 | |> Seq.tryHead 81 | 82 | let internal replace (curies: Curies) (map: Map>) = 83 | map 84 | |> Map.toList 85 | |> List.map (fun (relation, x) -> 86 | match tryFindName curies relation with 87 | | Some name -> name, x 88 | | _ -> relation, x) 89 | |> Map.ofList 90 | 91 | let rec replaceRelations curies resource = 92 | { resource with 93 | links = replace curies resource.links 94 | embedded = (replace curies resource.embedded) |> Map.map (fun _ emb -> emb |> MaybeSingleton.map (replaceRelations curies)) 95 | } 96 | 97 | [] 98 | [] 99 | module Link = 100 | let create href = { 101 | href = href 102 | templated = None 103 | mediaType = None 104 | deprication = None 105 | name = None 106 | profile = None 107 | title = None 108 | hreflang = None 109 | } 110 | 111 | let internal serializeSingleLink (link: Link) : JsonModel<'a> = 112 | Map.ofList 113 | [ yield ("href", JString (string link.href)) 114 | yield! match link.templated with Some b -> [ "templated", JBool b ] | _ -> [] 115 | yield! match link.mediaType with Some mt -> [ "type", JString mt ] | _ -> [] 116 | yield! match link.deprication with Some dep -> [ "deprication", JString (dep.ToString()) ] | _ -> [] 117 | yield! match link.name with Some name -> [ "name", JString name ] | _ -> [] 118 | yield! match link.profile with Some prof -> [ "profile", JString (prof.ToString()) ] | _ -> [] 119 | yield! match link.title with Some title -> [ "title", JString title ] | _ -> [] 120 | yield! match link.hreflang with Some lang -> [ "hreflang", JString lang ] | _ -> [] ] 121 | |> JRecord 122 | 123 | let internal serialize (links: Map>) (curies: Curies) : JsonModel<'a> option = 124 | 125 | let linksWithCuries = 126 | curies 127 | |> Map.toList 128 | |> List.map (fun (name, href) -> { create href with name = Some name; templated = Some true }) 129 | |> fun x -> "curies", Collection x 130 | |> links.Add 131 | 132 | let serializeLinkList = function 133 | | Singleton l -> serializeSingleLink l 134 | | Collection ls -> JArray (ls |> List.map serializeSingleLink) 135 | 136 | let nonEmptyLinks = 137 | linksWithCuries 138 | |> Map.filter (fun _ link -> 139 | match link with 140 | | Singleton _ -> true 141 | | Collection xs -> not (xs |> List.isEmpty)) 142 | 143 | if nonEmptyLinks |> Map.isEmpty then 144 | None 145 | else 146 | nonEmptyLinks 147 | |> Map.map (fun _ l -> serializeLinkList l) 148 | |> JRecord 149 | |> Some 150 | 151 | /// Contains functions to transform resources to Json representations. 152 | [] 153 | module Resource = 154 | 155 | /// Returns an empty resource object the represents a valid HAL resource. 156 | let empty = { 157 | curies = Map.empty 158 | links = Map.empty 159 | embedded = Map.empty 160 | properties = Map.empty 161 | payload = None 162 | } 163 | 164 | let internal tryToMap(x: 'T) = 165 | let objectToMap(obj: obj) = 166 | obj.GetType().GetProperties(BindingFlags.DeclaredOnly ||| BindingFlags.Public ||| BindingFlags.Instance) 167 | |> Array.map (fun prop -> prop.Name, prop.GetValue(obj, null)) 168 | |> Map.ofArray 169 | 170 | let recordToMap (record:'T) = 171 | [ for p in FSharpType.GetRecordFields(typeof<'T>) -> 172 | p.Name, p.GetValue(record) ] 173 | |> Map.ofSeq 174 | 175 | try 176 | if FSharp.Reflection.FSharpType.IsRecord(typeof<'T>) then 177 | x |> recordToMap 178 | else 179 | x |> objectToMap 180 | with 181 | | _ -> Map.empty 182 | 183 | let rec internal serialize (resource: Resource<'a>) : JsonModel<'a> = 184 | 185 | let withResolvedCuries = Curies.replaceRelations resource.curies resource 186 | 187 | let embedded = 188 | let embeddedMap = 189 | withResolvedCuries.embedded 190 | |> Map.map (fun rel res -> 191 | match res with 192 | | Singleton x -> serialize x 193 | | Collection xs -> JArray (xs |> List.map serialize)) 194 | 195 | if embeddedMap |> Map.isEmpty then 196 | Map.empty 197 | else 198 | Map.ofList [ "_embedded", embeddedMap |> JRecord ] 199 | 200 | let links = 201 | match Link.serialize withResolvedCuries.links resource.curies with 202 | | Some ls -> Map.ofList [ "_links", ls ] 203 | | _ -> Map.empty 204 | 205 | let properties = 206 | match resource.payload with 207 | | Some pl -> 208 | let payload = 209 | try 210 | pl |> tryToMap |> Map.map (fun _ v -> JObject (v :?> 'a)) 211 | with 212 | | _ -> Map.empty 213 | merge [ resource.properties; payload ] 214 | | _ -> resource.properties 215 | 216 | [ links; properties; embedded ] 217 | |> merge 218 | |> JRecord 219 | 220 | /// Serializes a HAL resource representation to a specific Json representation. 221 | /// The interpreter transforms the generic Json representation to a specific representation. 222 | let toJson interpreter resource = 223 | resource |> serialize |> interpreter 224 | 225 | let withPayload (payload: obj) resource : Resource = 226 | { resource with payload = payload |> Some } 227 | 228 | let withLinks links resource = 229 | { resource with links = Map.ofList links } 230 | let addLink rel link resource = 231 | { resource with links = resource.links.Add(rel, Singleton link) } 232 | 233 | let addLinkCollection rel link resource = 234 | { resource with links = resource.links.Add(rel, Collection link) } 235 | 236 | let addEmbedded name embedded resource = 237 | { resource with embedded = resource.embedded.Add(name, Singleton embedded) } 238 | 239 | let addEmbeddedCollection name embedded resource = 240 | { resource with embedded = resource.embedded.Add(name, Collection embedded) } 241 | 242 | let withCuries curies resource = 243 | { resource with curies = Map.ofList curies } 244 | 245 | let withProperties props resource = 246 | { resource with properties = props |> Map.ofList |> Map.map (fun _ x -> JObject x) } 247 | 248 | let addProperty name prop resource = 249 | { resource with properties = resource.properties.Add(name, JObject prop) } 250 | 251 | module Siren = 252 | 253 | open System 254 | 255 | type Rel = Rel of string 256 | type Title = Title of string 257 | type Class = Class of string 258 | type MediaType = MediaType of string 259 | type Href = Href of Uri 260 | type Name = Name of string 261 | type Value = Value of string 262 | 263 | type HttpMethod = GET | PUT | POST | DELETE | PATCH 264 | 265 | type InputType = 266 | | Hidden | Text | Search | Tel | Url | Email | Password 267 | | Datetime | Date | Month | Week | Time | DatetimeLocal | Number 268 | | Range | Color | Checkbox | Radio | File 269 | 270 | type Field = { 271 | classes: Class list 272 | inputType: InputType option 273 | value: Value option 274 | title: Title option 275 | } 276 | 277 | type Action = { 278 | classes: Class list 279 | httpMethod: HttpMethod option 280 | href: Href 281 | title: Title option 282 | mediaType: MediaType option 283 | fields: Map 284 | } 285 | 286 | type Link = { 287 | href: Href 288 | rel: Rel * Rel list 289 | classes: Class list 290 | title: Title option 291 | mediaType: MediaType option 292 | } 293 | 294 | type Entity<'a> = { 295 | properties: Map> 296 | entities: SubEntity<'a> list 297 | actions: Map 298 | links: Link list 299 | classes: Class list 300 | title: Title option 301 | } 302 | 303 | and SubEntity<'a> = 304 | | EmbeddedRepresentation of Entity<'a> * Rel 305 | | EmbeddedLink of Link 306 | 307 | let mkProperty name items = 308 | if items |> List.isEmpty then 309 | List.empty 310 | else 311 | [ name, JArray items ] 312 | 313 | [] 314 | module internal Attributes = 315 | let CLASS = "class" 316 | let HREF = "href" 317 | let TITLE = "title" 318 | let TYPE = "type" 319 | let FIELDS = "fields" 320 | let NAME = "name" 321 | let REL = "rel" 322 | let LINKS = "links" 323 | let VALUE = "value" 324 | let METHOD = "method" 325 | let ACTIONS = "actions" 326 | let ENTITIES = "entities" 327 | let PROPERTIES = "properties" 328 | 329 | [] 330 | [] 331 | module internal InputType = 332 | let serialize inputType = 333 | match inputType with 334 | | Hidden -> "hidden" 335 | | Text -> "text" 336 | | Search -> "search" 337 | | Tel -> "tel" 338 | | Url -> "url" 339 | | Email -> "email" 340 | | Password -> "password" 341 | | Datetime -> "datetime" 342 | | Date -> "date" 343 | | Month -> "month" 344 | | Week -> "week" 345 | | Time -> "time" 346 | | DatetimeLocal -> "datetime-local" 347 | | Number-> "number" 348 | | Range -> "range" 349 | | Color -> "color" 350 | | Checkbox -> "checkbox" 351 | | Radio -> "radio" 352 | | File-> "file" 353 | 354 | [] 355 | [] 356 | module internal HttpMethod = 357 | let serialize httpMethod = 358 | match httpMethod with 359 | | GET -> "GET" 360 | | PUT -> "PUT" 361 | | POST -> "POST" 362 | | DELETE -> "DELETE" 363 | | PATCH -> "PATCH" 364 | 365 | [] 366 | [] 367 | module internal Field = 368 | let empty : Field = { 369 | classes = List.empty 370 | inputType = None 371 | value = None 372 | title = None 373 | } 374 | 375 | let internal serialize name (field: Field) = 376 | field.classes |> List.map (fun (Class c) -> JString c) |> mkProperty CLASS 377 | |> fun props -> 378 | let inputType = 379 | match field.inputType with Some t -> t | _ -> InputType.Text 380 | |> InputType.serialize 381 | |> fun x -> TYPE, JString x 382 | inputType :: props 383 | |> fun props -> 384 | match field.value with Some (Value v) -> (VALUE, JString v) :: props | _ -> props 385 | |> fun props -> 386 | match field.title with Some (Title t) -> (TITLE, JString t) :: props | _ -> props 387 | |> fun props -> (NAME, JString name) :: props 388 | |> Map.ofList 389 | |> JRecord 390 | 391 | 392 | [] 393 | [] 394 | module internal Action = 395 | let create href : Action = { 396 | classes = List.empty 397 | httpMethod = None 398 | href = href 399 | title = None 400 | mediaType = None 401 | fields = Map.empty 402 | } 403 | 404 | let serialize name (action: Action) = 405 | action.classes |> List.map (fun (Class c) -> JString c) |> mkProperty CLASS 406 | |> fun props -> 407 | let httpMethod = 408 | match action.httpMethod with Some m -> m | _ -> HttpMethod.GET 409 | |> HttpMethod.serialize 410 | |> fun x -> METHOD, JString x 411 | httpMethod :: props 412 | |> fun props -> 413 | let (Href href) = action.href 414 | (HREF, JString (href.ToString())) :: props 415 | |> fun props -> 416 | match action.title with Some (Title t) -> (TITLE, JString t) :: props | _ -> props 417 | |> fun props -> 418 | let mediaType = 419 | match action.mediaType with Some (MediaType mt) -> mt | _ -> "application/x-www-form-urlencoded" 420 | |> fun x -> TYPE, JString x 421 | mediaType :: props 422 | |> fun props -> 423 | List.concat [ props; action.fields |> Map.toList |> List.map (fun (Name n, f) -> f |> Field.serialize n) |> mkProperty FIELDS ] 424 | |> fun props -> (NAME, JString name) :: props 425 | |> Map.ofList 426 | |> JRecord 427 | 428 | [] 429 | [] 430 | module Link = 431 | 432 | let create rel href : Link = { 433 | href = href 434 | rel = rel, [] 435 | classes = List.empty 436 | title = None 437 | mediaType = None 438 | } 439 | 440 | let internal serialize (link: Link) = 441 | link.classes |> List.map (fun (Class c) -> JString c) |> mkProperty CLASS 442 | |> fun props -> 443 | let (Href href) = link.href 444 | (HREF, JString (href.ToString())) :: props 445 | |> fun props -> 446 | match link.title with Some (Title t) -> (TITLE, JString t) :: props | _ -> props 447 | |> fun props -> 448 | match link.mediaType with Some (MediaType mt) -> (TYPE, JString mt) :: props | _ -> props 449 | |> fun props -> 450 | List.concat [ props; fst link.rel :: snd link.rel |> List.map (fun (Rel rel) -> JString rel) |> mkProperty REL ] 451 | |> Map.ofList 452 | |> JRecord 453 | 454 | let withClasses classes link : Link = 455 | { link with classes = classes |> List.map Class } 456 | 457 | [] 458 | [] 459 | module internal Entity = 460 | 461 | let empty : Entity<'a> = { 462 | properties = Map.empty 463 | entities = List.empty 464 | actions = Map.empty 465 | links = List.empty 466 | classes = List.empty 467 | title = None 468 | } 469 | 470 | let rec serializeRec (rel: Rel option) (entity: Entity<'a>) = 471 | entity.classes |> List.map (fun (Class c) -> JString c) |> mkProperty CLASS 472 | |> fun props -> 473 | match entity.title with Some (Title t) -> (TITLE, JString t) :: props | _ -> props 474 | |> fun props -> 475 | match rel with Some (Rel r) -> (REL, JArray [ JString r ]) :: props | _ -> props 476 | |> fun props -> 477 | if entity.properties |> (not << Map.isEmpty) then 478 | let properties = (PROPERTIES, entity.properties |> Map.toList |> List.map (fun (Name n, v) -> n,v) |> Map.ofList |> JRecord) 479 | properties :: props 480 | else 481 | props 482 | |> fun props -> 483 | let embedded = 484 | entity.entities 485 | |> List.map 486 | (function 487 | | EmbeddedRepresentation (e,r) -> serializeRec (Some r) e 488 | | EmbeddedLink link -> link |> Link.serialize) 489 | |> mkProperty ENTITIES 490 | [ embedded; props ] 491 | |> fun propss -> 492 | (entity.links |> List.map Link.serialize |> mkProperty LINKS) :: propss 493 | |> fun propss -> 494 | List.concat ((entity.actions |> Map.toList |> List.map (fun (Name n, v) -> Action.serialize n v) |> mkProperty ACTIONS) :: propss) 495 | |> Map.ofList 496 | |> JRecord 497 | 498 | let withClasses classes entity : Entity<_> = 499 | { entity with classes = classes |> List.map Class } 500 | 501 | let addProperty name prop entity: Entity<'a> = 502 | { entity with properties = entity.properties.Add(Name name, JObject prop) } 503 | 504 | let withLinks links entity: Entity<'a> = 505 | { entity with links = links } 506 | 507 | let addEmbeddedLink link entity = 508 | { entity with entities = EmbeddedLink link :: entity.entities } 509 | 510 | let addEmbeddedEntity embedded rel entity = 511 | { entity with entities = EmbeddedRepresentation (embedded, Rel rel) :: entity.entities } 512 | 513 | let withActions actions entity = 514 | { entity with actions = actions |> List.map (fun (k,v) -> Name k, v) |> Map.ofList } 515 | 516 | let serialize entity = serializeRec None entity 517 | 518 | let toJson interpreter entity = 519 | entity |> serialize |> interpreter 520 | 521 | /// Contains the interpreter to transform an `JsonModel` into an `obj`. 522 | module ObjectInterpreter = 523 | 524 | /// Transforms an `JsonModel` into an `obj`. 525 | let rec interpret (instance: JsonModel) : obj = 526 | match instance with 527 | | JObject a -> a 528 | | JBool b -> b :> obj 529 | | JString s -> s :> obj 530 | | JRecord map -> map |> Map.map (fun _ v -> interpret v) :> obj 531 | | JArray a -> a |> List.map interpret :> obj 532 | 533 | [] 534 | module Siren = 535 | let toJson entity = Siren.Entity.toJson interpret entity 536 | 537 | [] 538 | module Hal = 539 | /// Serializes a HAL resource as `obj` 540 | let toJson resource = Hal.Resource.toJson interpret resource 541 | 542 | module FSharpDataIntepreter = 543 | 544 | open FSharp.Data 545 | 546 | /// Transforms an `JsonModel` into a `FSharp.Data.JsonValue`. 547 | let rec internal interpret (instance: JsonModel) : JsonValue = 548 | match instance with 549 | | JObject a -> a 550 | | JBool b -> JsonValue.Boolean b 551 | | JString s -> JsonValue.String s 552 | | JRecord map -> JsonValue.Record (map |> Map.toArray |> Array.map (fun (k,v) -> k, interpret v)) 553 | | JArray a -> JsonValue.Array (a |> List.map interpret |> List.toArray) 554 | 555 | [] 556 | module Hal = 557 | /// Serializes a HAL resource as `FSharp.Data.JsonValue` 558 | let toJson resource = Hal.Resource.toJson interpret resource 559 | 560 | [] 561 | module Siren = 562 | let toJSon entity = Siren.Entity.toJson interpret entity -------------------------------------------------------------------------------- /src/TicTacToe.Dsls.fsx: -------------------------------------------------------------------------------- 1 | module TicTacToe.Dsls 2 | 3 | #load "TicTacToe.fsx" 4 | 5 | open TicTacToe 6 | 7 | type Continuation<'output, 'next> = 'output -> 'next 8 | 9 | 10 | module Domain = 11 | type Domain<'next> = 12 | | Handle of (State * Command) * Continuation 13 | | Replay of Event list * Continuation 14 | 15 | let map f x = 16 | match x with 17 | | Handle(v, cont) -> Handle(v, cont >> f) 18 | | Replay(v, cont) -> Replay(v, cont >> f) 19 | 20 | 21 | module ReadModel = 22 | type GridRm = string list list 23 | type GameRm = { 24 | id: string 25 | grid: GridRm 26 | status: string 27 | } 28 | type GameListItemRm = { 29 | id: string 30 | status: string 31 | } 32 | 33 | type ReadModel<'next> = 34 | | SubscribeToEventBus of unit * Continuation 35 | | Game of GameId * Continuation 36 | | Games of unit * Continuation 37 | 38 | let map f x = 39 | match x with 40 | | SubscribeToEventBus(v, cont) -> SubscribeToEventBus(v, cont >> f) 41 | | Game(v, cont) -> Game(v, cont >> f) 42 | | Games(v, cont) -> Games(v, cont >> f) 43 | 44 | 45 | module EventStore = 46 | type EventStore<'next> = 47 | | GetStream of GameId * 48 | Continuation 49 | | Append of (GameId * Version * Event list) * 50 | Continuation 51 | 52 | let map f x = 53 | match x with 54 | | GetStream(v, cont) -> GetStream(v, cont >> f) 55 | | Append(v, cont) -> Append(v, cont >> f) 56 | 57 | 58 | module EventBus = 59 | type EventBus<'next> = 60 | | Publish of (GameId * Event list) * Continuation 61 | let map f x = 62 | match x with 63 | | Publish(v, cont) -> Publish(v, cont >> f) 64 | 65 | 66 | module TicTacToeDsl = 67 | type TicTacToeDsl<'next> = 68 | | Domain of Domain.Domain<'next> 69 | | EventStore of EventStore.EventStore<'next> 70 | | ReadModel of ReadModel.ReadModel<'next> 71 | | EventBus of EventBus.EventBus<'next> 72 | 73 | 74 | let map (f: 'a -> 'b) (dsl : TicTacToeDsl<'a>) : TicTacToeDsl<'b> = 75 | match dsl with 76 | | Domain d -> Domain.map f d |> Domain 77 | | EventStore es -> EventStore.map f es |> EventStore 78 | | EventBus bus -> EventBus.map f bus |> EventBus 79 | | ReadModel rm -> ReadModel.map f rm |> ReadModel 80 | 81 | 82 | module Free = 83 | open TicTacToeDsl 84 | 85 | type Free<'a> = 86 | | Pure of 'a 87 | | Free of TicTacToeDsl> 88 | 89 | [] 90 | module FreeMonad = 91 | let rec bind (f: 'a -> Free<'b>) (dsl : Free<'a>) : Free<'b> = 92 | match dsl with 93 | | Pure value -> f value 94 | | Free t -> map (bind f) t |> Free 95 | 96 | let liftF(dsl:TicTacToeDsl<'a>) : Free<'a> = 97 | Free (map Pure dsl) 98 | 99 | type FreeBuilder() = 100 | member x.Bind(dsl, f) = FreeMonad.bind f dsl 101 | member x.Return(value) = Pure value 102 | member x.Zero() = Pure () 103 | 104 | let free = new FreeBuilder() -------------------------------------------------------------------------------- /src/TicTacToe.Instructions.fsx: -------------------------------------------------------------------------------- 1 | module TicTacToe.Instructions 2 | 3 | #load "TicTacToe.Dsls.fsx" 4 | 5 | open Dsls.Free 6 | open Dsls.TicTacToeDsl 7 | 8 | module private Domain = 9 | open Dsls.Domain 10 | 11 | let handle v = FreeMonad.liftF(Handle(v, id) |> Domain) 12 | let replay v = FreeMonad.liftF(Replay(v, id) |> Domain) 13 | 14 | module private EventBus = 15 | open Dsls.EventBus 16 | 17 | let publish v = FreeMonad.liftF(Publish(v, id) |> EventBus) 18 | 19 | module private EventStore = 20 | open Dsls.EventStore 21 | 22 | let append v = FreeMonad.liftF(Append(v, id) |> EventStore) 23 | let getStream v = FreeMonad.liftF(GetStream(v, id) |> EventStore) 24 | 25 | module ReadModel = 26 | open Dsls.ReadModel 27 | 28 | let subscribe v = FreeMonad.liftF(SubscribeToEventBus(v, id) |> ReadModel) 29 | 30 | module Queries = 31 | open Dsls.ReadModel 32 | 33 | let game v = FreeMonad.liftF(Game(v, id) |> ReadModel) 34 | let games = FreeMonad.liftF(Games((), id) |> ReadModel) 35 | 36 | module Commands = 37 | let handle (id: GameId, cmd: Command) = 38 | free { 39 | let! events = EventStore.getStream id 40 | let! state = Domain.replay events 41 | let! (v, newEvents) = Domain.handle (state, cmd) 42 | do! EventStore.append (id, v, newEvents) 43 | do! EventBus.publish (id, newEvents) 44 | return () 45 | } 46 | -------------------------------------------------------------------------------- /src/TicTacToe.Interpreters.fsx: -------------------------------------------------------------------------------- 1 | module TicTacToe.Interpreters 2 | 3 | #load "TicTacToe.Instructions.fsx" 4 | 5 | open TicTacToe 6 | open TicTacToe.Instructions 7 | open Dsls 8 | 9 | #nowarn "40" 10 | type Actor<'T> = MailboxProcessor<'T> 11 | 12 | module Effects = 13 | open Chessie.ErrorHandling 14 | 15 | type Error = string 16 | type Effect<'a> = AsyncResult<'a, Error> 17 | let ofResult r: Effect<'a> = r |> Async.singleton |> AR 18 | 19 | let effects = asyncTrial 20 | 21 | let bind (f: 'a -> Effect<'b>) (x: Effect<'a>): Effect<'b> = 22 | effects { 23 | let! v = x 24 | return! f v 25 | } 26 | 27 | let (>>=) x f = bind f x 28 | 29 | let singleton x: Effect<'a> = ok x |> ofResult 30 | 31 | 32 | module TicTacToe = 33 | open Chessie.ErrorHandling 34 | open Effects 35 | open Dsls.TicTacToeDsl 36 | open Free 37 | 38 | let rec interpret dom chan es rm dsl = 39 | let interpretRec = interpret dom chan es rm 40 | match dsl with 41 | | Pure v -> singleton v 42 | | Free free -> 43 | match free with 44 | | Domain x -> dom x >>= interpretRec 45 | | EventBus x -> chan x >>= interpretRec 46 | | EventStore x -> es x >>= interpretRec 47 | | ReadModel x -> rm x >>= interpretRec 48 | 49 | 50 | module Domain = 51 | open Chessie.ErrorHandling 52 | open Effects 53 | open Dsls.Domain 54 | 55 | let interpret dsl: Effect<'a> = 56 | match dsl with 57 | | Handle((state, cmd), cont) -> 58 | Domain.handle state cmd |> Trial.lift cont |> Effects.ofResult 59 | | Replay(events, cont) -> 60 | Domain.replay events |> Trial.lift cont |> Effects.ofResult 61 | 62 | 63 | module EventStore = 64 | open Chessie.ErrorHandling 65 | open Effects 66 | open Dsls.EventStore 67 | 68 | type Stream = { mutable events: (Event * Version) list } 69 | 70 | type EventStoreMsg = 71 | | AppendToStream of string * int * Event list * AsyncReplyChannel> 72 | | TryGetStream of string * AsyncReplyChannel 73 | 74 | let eventStoreActor = 75 | Actor.Start(fun inbox -> 76 | let rec loop (eventStore: Map) = 77 | async { 78 | let! msg = inbox.Receive() 79 | match msg with 80 | | AppendToStream (streamId, v, events, rc) -> 81 | let eventsWithVersion = 82 | events 83 | |> List.mapi (fun i e -> (e, Version (v + i + 1))) 84 | match eventStore.TryFind streamId with 85 | | Some(stream) -> 86 | if (stream.events |> List.last |> snd) <> Version v then 87 | rc.Reply(fail "resource has been modified, cannot make changes") 88 | else 89 | ignore (stream.events <- stream.events @ eventsWithVersion) 90 | rc.Reply(ok ()) 91 | return! loop eventStore 92 | | None -> 93 | rc.Reply(ok ()) 94 | return! loop (eventStore.Add(streamId, { events = eventsWithVersion })) 95 | | TryGetStream (streamId, rc) -> 96 | rc.Reply(eventStore.TryFind streamId) 97 | return! loop eventStore 98 | } 99 | loop Map.empty) 100 | 101 | let interpret dsl = 102 | match dsl with 103 | | GetStream(GameId gId, cont) -> 104 | async { 105 | let! maybeStream = eventStoreActor.PostAndAsyncReply(fun rc -> TryGetStream (gId.ToString(), rc)) 106 | return 107 | match maybeStream with 108 | | Some(stream) -> 109 | stream.events 110 | |> List.map fst 111 | | None -> [] 112 | |> cont |> ok 113 | } |> AR 114 | 115 | | Append(((GameId gId), (Version v), newEvents), cont) -> 116 | async { 117 | let! result = eventStoreActor.PostAndAsyncReply(fun rc -> AppendToStream (gId.ToString(), v, newEvents, rc)) 118 | return cont result 119 | } |> AR 120 | 121 | 122 | module EventBus = 123 | open Chessie.ErrorHandling 124 | open Effects 125 | open Dsls.EventBus 126 | 127 | type Actor<'T> = MailboxProcessor<'T> 128 | 129 | type EventBusMsg = 130 | | Subscribe of (GameId * Event -> unit) 131 | | PublishEvents of GameId * Event list 132 | 133 | let eventBusActor = 134 | Actor.Start(fun inbox -> 135 | let rec loop (handlers: List unit>) = 136 | async { 137 | let! msg = inbox.Receive() 138 | match msg with 139 | | Subscribe handler -> 140 | return! loop (handler :: handlers) 141 | | PublishEvents (id, events) -> 142 | handlers |> List.iter (fun handle -> 143 | events |> List.iter (fun event -> 144 | handle (id, event))) 145 | return! loop handlers 146 | } 147 | loop List.empty) 148 | 149 | let interpret dsl = 150 | match dsl with 151 | | Publish((id, events), cont) -> 152 | do eventBusActor.Post(PublishEvents(id, events)) 153 | () |> cont |> ok |> Effects.ofResult 154 | 155 | 156 | module ReadModel = 157 | open Chessie.ErrorHandling 158 | open Effects 159 | open Dsls.ReadModel 160 | 161 | let emptyGridRm = [ 162 | [""; ""; ""] 163 | [""; ""; ""] 164 | [""; ""; ""] 165 | ] 166 | 167 | let updateGrid (rm: GameRm) pos mark = 168 | let update m (grid: GridRm) (v,h) = 169 | grid |> List.mapi (fun v' hLine -> 170 | if v' = v then 171 | hLine 172 | |> List.mapi (fun h' m' -> if h' = h then m else m') 173 | else 174 | hLine) 175 | 176 | let mapToCoords (v,h) = 177 | match (v,h) with 178 | | Top, Left -> 0,0 179 | | Top, HCenter -> 0,1 180 | | Top, Right -> 0,2 181 | | VCenter, Left -> 1,0 182 | | VCenter, HCenter -> 1,1 183 | | VCenter, Right -> 1,2 184 | | Bottom, Left -> 2,0 185 | | Bottom, HCenter -> 2,1 186 | | Bottom, Right -> 2,2 187 | 188 | mapToCoords pos |> update mark rm.grid 189 | 190 | type ReadModelMsg = 191 | | AddGame of GameId * string 192 | | UpdateGame of GameId * Position * string * string 193 | | UpdateGameStatus of GameId * string 194 | | UpsertGames of GameId * GameListItemRm 195 | | TryFind of GameId * AsyncReplyChannel 196 | | GameList of AsyncReplyChannel 197 | 198 | let readModelsActor = 199 | Actor.Start(fun inbox -> 200 | let rec loop (games: Map, gameList: Map) = 201 | async { 202 | let! msg = inbox.Receive() 203 | match msg with 204 | | AddGame (GameId gameId, status) -> 205 | return! loop (games.Add(GameId gameId, { id = gameId.ToString(); grid = emptyGridRm; status = status }), gameList) 206 | | UpdateGameStatus (gameId, status) -> 207 | match games.TryFind gameId with 208 | | Some ({ id = id; grid = grid; status = _ }) -> 209 | return! loop (games.Add(gameId, { id = id; grid = grid; status = status }), gameList) 210 | | _ -> return! loop (games, gameList) 211 | | UpdateGame (gameId, pos, marker, status) -> 212 | match games.TryFind gameId with 213 | | Some rm -> 214 | let grid = updateGrid rm pos marker 215 | return! loop (games.Add(gameId, { rm with grid = grid; status = status }), gameList) 216 | | None -> return! loop (games, gameList) 217 | | UpsertGames (gameId, listItem) -> 218 | return! loop (games, gameList.Add(gameId, listItem)) 219 | | TryFind (gameId, rc) -> 220 | rc.Reply(games.TryFind gameId) 221 | return! loop (games, gameList) 222 | | GameList rc -> 223 | rc.Reply(gameList |> Map.toList |> List.map snd) 224 | return! loop (games, gameList) 225 | } 226 | loop (Map.empty, Map.empty)) 227 | 228 | let eventHandler (GameId id, event) = 229 | match event with 230 | | Started -> 231 | do readModelsActor.Post(UpsertGames(GameId id, { id = id.ToString(); status = "running" })) 232 | do readModelsActor.Post(AddGame(GameId id, "player X to play")) 233 | | PlayerXPlayed pos -> 234 | do readModelsActor.Post(UpdateGame(GameId id, pos, "X", "player O to play")) 235 | do readModelsActor.Post(UpdateGameStatus(GameId id, "player O to play")) 236 | | PlayerOPlayed pos -> 237 | do readModelsActor.Post(UpdateGame(GameId id, pos, "O", "player X to play")) 238 | do readModelsActor.Post(UpdateGameStatus(GameId id, "player X to play")) 239 | | PlayerXWon -> 240 | do readModelsActor.Post(UpsertGames(GameId id, { id = id.ToString(); status = "finished (player X won)" })) 241 | do readModelsActor.Post(UpdateGameStatus(GameId id, "player X won")) 242 | | PlayerOWon -> 243 | do readModelsActor.Post(UpsertGames(GameId id, { id = id.ToString(); status = "finished (player O won)" })) 244 | do readModelsActor.Post(UpdateGameStatus(GameId id, "player O won")) 245 | | Tied -> 246 | do readModelsActor.Post(UpsertGames(GameId id, { id = id.ToString(); status = "finished (tie)" })) 247 | do readModelsActor.Post(UpdateGameStatus(GameId id, "tie")) 248 | 249 | let interpret dsl = 250 | match dsl with 251 | | SubscribeToEventBus(_, cont) -> 252 | do EventBus.eventBusActor.Post(EventBus.Subscribe(eventHandler)) 253 | () |> cont |> Effects.singleton 254 | | Game(id, cont) -> 255 | async{ 256 | let! maybeGame = readModelsActor.PostAndAsyncReply(fun rc -> TryFind(id, rc)) 257 | return 258 | maybeGame |> Trial.failIfNone "not found" 259 | |> Trial.lift cont 260 | } |> AR 261 | | Games(_, cont) -> 262 | async { 263 | let! games = readModelsActor.PostAndAsyncReply(fun rc -> GameList(rc)) 264 | return games |> cont |> ok 265 | } |> AR 266 | -------------------------------------------------------------------------------- /src/TicTacToe.fsx: -------------------------------------------------------------------------------- 1 | module TicTacToe 2 | 3 | #r "../packages/Chessie/lib/net40/Chessie.dll" 4 | 5 | open System 6 | 7 | [] 8 | module Types = 9 | open Chessie.ErrorHandling 10 | 11 | type GameId = GameId of Guid 12 | 13 | type Marker = X | O | Empty 14 | 15 | type Horizontal = Left | HCenter | Right 16 | type Vertical = Top | VCenter | Bottom 17 | 18 | type Position = Vertical * Horizontal 19 | 20 | type Square = Vertical * Horizontal * Marker 21 | 22 | type Player = 23 | | PlayerX 24 | | PlayerO 25 | 26 | type Grid = Square list 27 | 28 | type Game = 29 | | Initial 30 | | PlayerXToPlay of Grid 31 | | PlayerOToPlay of Grid 32 | | PlayerXWins of Grid 33 | | PlayerOWins of Grid 34 | | Tie of Grid 35 | 36 | type Version = Version of int 37 | type State = Version * Game 38 | 39 | type Error = String 40 | 41 | type Command = 42 | | Start 43 | | PlayX of Position 44 | | PlayO of Position 45 | 46 | type Event = 47 | | Started 48 | | PlayerXPlayed of Position 49 | | PlayerOPlayed of Position 50 | | PlayerXWon 51 | | PlayerOWon 52 | | Tied 53 | 54 | type VersionedEvents = GameId * Version * Event list 55 | 56 | 57 | module Domain = 58 | open Chessie.ErrorHandling 59 | let private hLines = [ 60 | [(Top, Left); (Top, HCenter); (Top, Right)] 61 | [(VCenter, Left); (VCenter, HCenter); (VCenter, Right)] 62 | [(Bottom, Left); (Bottom, HCenter); (Bottom, Right)] 63 | ] 64 | 65 | let private vLines = [ 66 | [(Top, Left); (VCenter, Left); (Bottom, Left)] 67 | [(Top, HCenter); (VCenter, HCenter); (Bottom, HCenter)] 68 | [(Top, Right); (VCenter, Right); (Bottom, Right)] 69 | ] 70 | 71 | let private diag = [ 72 | [(Top, Left); (VCenter, HCenter); (Bottom, Right)] 73 | [(Bottom, Left); (VCenter, HCenter); (Top, Right)] 74 | ] 75 | 76 | let private marker = function PlayerX _ -> X | PlayerO _ -> O 77 | 78 | let private equals (v, h) (v', h', _) = v' = v && h' = h 79 | 80 | let private placeMarker grid player (v, h) = 81 | if grid |> List.exists (equals (v, h)) then 82 | fail "square already marked" 83 | else 84 | ok ((v, h, marker player) :: grid, player, v, h) 85 | 86 | let private getMarker (grid: Grid) pos = 87 | let square = grid |> List.tryFind (equals pos) 88 | match square with 89 | | Some (_,_,m) -> m 90 | | None -> Empty 91 | 92 | let private isLineOnlyPlayerBy player grid line = 93 | line 94 | |> List.map (getMarker grid) 95 | |> List.forall (fun m -> 96 | match m, player with 97 | | X, PlayerX -> true 98 | | O, PlayerO -> true 99 | | _ -> false) 100 | 101 | let private eval (grid, player, v, h) = 102 | let wins = isLineOnlyPlayerBy player grid 103 | 104 | match hLines @ vLines @ diag |> List.exists wins, player with 105 | | true, PlayerX -> PlayerXWins grid 106 | | true, PlayerO -> PlayerOWins grid 107 | | _, PlayerX when grid |> List.length < 9 -> PlayerOToPlay grid 108 | | _, PlayerO when grid |> List.length < 9 -> PlayerXToPlay grid 109 | | _ -> Tie grid 110 | 111 | let replay (events: Event list) : Result = 112 | 113 | let replayError errs = errs |> List.map (fun err -> sprintf "replay error, %s" err) 114 | 115 | let apply state event: Result = 116 | match state, event with 117 | | Initial, Started -> 118 | PlayerXToPlay [] |> ok 119 | | PlayerXToPlay grid, PlayerXPlayed pos -> 120 | placeMarker grid PlayerX pos |> Trial.lift eval |> Trial.mapFailure replayError 121 | | PlayerOToPlay grid, PlayerOPlayed pos -> 122 | placeMarker grid PlayerO pos |> Trial.lift eval |> Trial.mapFailure replayError 123 | | PlayerXToPlay grid, _ -> 124 | fail "replay error, wrong turn" 125 | | PlayerOToPlay grid, _ -> fail "replay error, wrong turn" 126 | | _ -> 127 | fail "replay error, game is finished" 128 | 129 | let folder errorOrState event = 130 | trial { 131 | let! (Version v, s) = errorOrState 132 | let! game = apply s event 133 | return v + 1 |> Version, game 134 | } 135 | 136 | events |> List.fold folder (ok (Version -1, Initial)) 137 | 138 | let handle (version: Version, game: Game) (cmd: Command): Result = 139 | let xToPlayAndXPlays grid pos = 140 | trial { 141 | let! state = placeMarker grid PlayerX pos 142 | let evaluatedState = eval state 143 | let events = 144 | match evaluatedState with 145 | | PlayerOToPlay _ -> [PlayerXPlayed pos] 146 | | PlayerXWins _ -> [PlayerXPlayed pos; PlayerXWon] 147 | | _ -> [] 148 | return version, events 149 | } 150 | 151 | let oToPlayAndOPlays grid pos = 152 | trial { 153 | let! state = placeMarker grid PlayerO pos 154 | let evaluatedState = eval state 155 | let events = 156 | match evaluatedState with 157 | | PlayerXToPlay _ -> [PlayerOPlayed pos] 158 | | PlayerOWins _ -> [PlayerOPlayed pos; PlayerOWon] 159 | | Tie _ -> [PlayerOPlayed pos; Tied] 160 | | _ -> [] 161 | return version, events 162 | } 163 | 164 | match game, cmd with 165 | | Initial, Start -> 166 | ok (version, [Started]) 167 | | PlayerXToPlay grid, PlayX pos -> 168 | xToPlayAndXPlays grid pos 169 | | PlayerOToPlay grid, PlayO pos -> 170 | oToPlayAndOPlays grid pos 171 | | PlayerXToPlay grid, PlayO _ -> 172 | fail "not your turn" 173 | | PlayerOToPlay grid, PlayX _ -> 174 | fail "not your turn" 175 | | _ -> fail "game is finished" 176 | --------------------------------------------------------------------------------