├── .DS_Store ├── .gitignore ├── .tool-versions ├── priv └── assets │ ├── favicon.ico │ ├── script.js │ └── styles.css ├── test └── luster_test.gleam ├── .github └── workflows │ └── test.yml ├── src ├── luster │ ├── line_poker │ │ ├── store.gleam │ │ ├── computer_player.gleam │ │ ├── session.gleam │ │ └── game.gleam │ ├── pubsub.gleam │ ├── web │ │ ├── layout.gleam │ │ ├── home │ │ │ └── view.gleam │ │ └── line_poker │ │ │ ├── socket.gleam │ │ │ └── view.gleam │ └── web.gleam ├── store.ex └── luster.gleam ├── README.md ├── gleam.toml └── manifest.toml /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chouzar/luster/HEAD/.DS_Store -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.beam 2 | *.ez 3 | build 4 | erl_crash.dump 5 | store/* -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 26.0.2 2 | elixir 1.15.5-otp-26 3 | gleam 1.0.0 4 | -------------------------------------------------------------------------------- /priv/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chouzar/luster/HEAD/priv/assets/favicon.ico -------------------------------------------------------------------------------- /test/luster_test.gleam: -------------------------------------------------------------------------------- 1 | import gleeunit 2 | import gleeunit/should 3 | 4 | pub fn main() { 5 | gleeunit.main() 6 | } 7 | 8 | // gleeunit test functions end in `_test` 9 | pub fn hello_world_test() { 10 | 1 11 | |> should.equal(1) 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | pull_request: 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3.2.0 15 | - uses: erlef/setup-beam@v1.15.2 16 | with: 17 | otp-version: "25.2" 18 | gleam-version: "0.26.1" 19 | rebar3-version: "3" 20 | # elixir-version: "1.14.2" 21 | - run: gleam format --check src test 22 | - run: gleam deps download 23 | - run: gleam test 24 | -------------------------------------------------------------------------------- /src/luster/line_poker/store.gleam: -------------------------------------------------------------------------------- 1 | pub type Record { 2 | Record(id: Int, name: String, document: String) 3 | } 4 | 5 | @external(erlang, "Elixir.Luster.Store", "start") 6 | pub fn start() -> Nil 7 | 8 | @external(erlang, "Elixir.Luster.Store", "all") 9 | pub fn all() -> List(Record) 10 | 11 | @external(erlang, "Elixir.Luster.Store", "put") 12 | pub fn put(index: Int, record: Record) -> Nil 13 | 14 | @external(erlang, "Elixir.Luster.Store", "get") 15 | pub fn get(index: Int) -> Result(Record, Nil) 16 | 17 | @external(erlang, "Elixir.Luster.Store", "dump") 18 | pub fn clean() -> Nil 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # luster 2 | 3 | [![Package Version](https://img.shields.io/hexpm/v/luster)](https://hex.pm/packages/luster) 4 | [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/luster/) 5 | 6 | A Gleam project 7 | 8 | ## Quick start 9 | 10 | ```sh 11 | gleam run # Run the project 12 | gleam test # Run the tests 13 | gleam shell # Run an Erlang shell 14 | ``` 15 | 16 | ## Installation 17 | 18 | If available on Hex this package can be added to your Gleam project: 19 | 20 | ```sh 21 | gleam add luster 22 | ``` 23 | 24 | and its documentation can be found at . 25 | -------------------------------------------------------------------------------- /src/store.ex: -------------------------------------------------------------------------------- 1 | defmodule Luster.Store do 2 | def start() do 3 | CubDB.start_link(data_dir: "store/", name: __MODULE__) 4 | nil 5 | end 6 | 7 | def all() do 8 | CubDB.select(__MODULE__) 9 | |> Enum.map(fn {_key, value} -> value end) 10 | end 11 | 12 | def put(key, value) do 13 | CubDB.put(__MODULE__, key, value) 14 | nil 15 | end 16 | 17 | def get(key) do 18 | case CubDB.fetch(__MODULE__, key) do 19 | {:ok, value} -> {:ok, value} 20 | :error -> {:error, nil} 21 | end 22 | end 23 | 24 | def dump() do 25 | CubDB.clear(__MODULE__) 26 | nil 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /priv/assets/script.js: -------------------------------------------------------------------------------- 1 | const session_id = document.querySelector("meta[name='session-id']").content; 2 | const is_live = document.querySelector("meta[name='live-session']").content; 3 | 4 | if (session_id && is_live) { 5 | const body = document.querySelector("body"); 6 | const socket = new WebSocket("wss://localhost:4444/events/" + session_id); 7 | 8 | socket.onmessage = (event) => { 9 | let [html, ...rest] = event.data.split("\n\n") 10 | body.innerHTML = html 11 | }; 12 | 13 | window.addEventListener('click', (event) => { 14 | if (event.target.dataset.event) { 15 | socket.send(new Blob([event.target.dataset.event])); 16 | } 17 | }); 18 | } -------------------------------------------------------------------------------- /gleam.toml: -------------------------------------------------------------------------------- 1 | name = "luster" 2 | version = "0.1.0" 3 | description = "A Gleam project" 4 | gleam = ">= 0.32.0" 5 | 6 | # Fill out these fields if you intend to generate HTML documentation or publish 7 | # your project to the Hex package manager. 8 | # 9 | # licences = ["Apache-2.0"] 10 | # repository = { type = "github", user = "username", repo = "project" } 11 | # links = [{ title = "Website", href = "https://gleam.run" }] 12 | 13 | [dependencies] 14 | gleam_stdlib = "~> 0.34" 15 | gleam_erlang = "~> 0.24" 16 | gleam_otp = "~> 0.9" 17 | gleam_http = "~> 3.5" 18 | gleam_json = "~> 1.0" 19 | mist = "~> 0.15" 20 | chip = "~> 0.2.1" 21 | nakai = "~> 0.9.0" 22 | envoy = "~> 1.0" 23 | cubdb = "~> 2.0" 24 | 25 | 26 | [dev-dependencies] 27 | gleeunit = "~> 1.0" 28 | -------------------------------------------------------------------------------- /src/luster/pubsub.gleam: -------------------------------------------------------------------------------- 1 | import chip 2 | import gleam/erlang/process 3 | import gleam/list 4 | import gleam/otp/actor 5 | 6 | pub type PubSub(channel, message) = 7 | process.Subject(chip.Message(channel, message)) 8 | 9 | pub fn start() -> Result(PubSub(channel, message), actor.StartError) { 10 | chip.start() 11 | } 12 | 13 | pub fn register( 14 | pubsub: PubSub(channel, message), 15 | channel: channel, 16 | subject: process.Subject(message), 17 | ) -> Nil { 18 | let _ = chip.register_as(pubsub, channel, fn() { Ok(subject) }) 19 | Nil 20 | } 21 | 22 | pub fn broadcast( 23 | pubsub: PubSub(channel, message), 24 | channel: channel, 25 | message: message, 26 | ) -> Nil { 27 | chip.lookup(pubsub, channel) 28 | |> list.map(fn(subject) { process.send(subject, message) }) 29 | 30 | Nil 31 | } 32 | -------------------------------------------------------------------------------- /src/luster/web/layout.gleam: -------------------------------------------------------------------------------- 1 | import nakai/html 2 | import nakai/html/attrs 3 | 4 | pub fn view( 5 | session_id: String, 6 | live_session: Bool, 7 | body: html.Node(a), 8 | ) -> html.Node(a) { 9 | let live_session = case live_session { 10 | True -> "true" 11 | False -> "" 12 | } 13 | 14 | html.Html([], [ 15 | html.Head([ 16 | html.title("Line Poker"), 17 | html.meta([attrs.name("viewport"), attrs.content("width=device-width")]), 18 | html.meta([attrs.name("session-id"), attrs.content(session_id)]), 19 | html.meta([attrs.name("live-session"), attrs.content(live_session)]), 20 | html.link([ 21 | attrs.rel("icon"), 22 | attrs.type_("image/x-icon"), 23 | attrs.defer(), 24 | attrs.href("/assets/favicon.ico"), 25 | ]), 26 | html.link([ 27 | attrs.rel("stylesheet"), 28 | attrs.type_("text/css"), 29 | attrs.defer(), 30 | attrs.href("/assets/styles.css"), 31 | ]), 32 | html.Element( 33 | tag: "script", 34 | attrs: [attrs.src("/assets/script.js"), attrs.defer()], 35 | children: [], 36 | ), 37 | ]), 38 | html.Body([], [body]), 39 | ]) 40 | } 41 | -------------------------------------------------------------------------------- /src/luster.gleam: -------------------------------------------------------------------------------- 1 | import envoy 2 | import gleam/erlang/process 3 | import gleam/http/request 4 | import gleam/http/response 5 | import luster/line_poker/session 6 | import luster/line_poker/store 7 | import luster/pubsub 8 | import luster/web 9 | import mist 10 | 11 | // TODO: Add a proper supervision tree. 12 | 13 | // TODO: Add a proper ELMish loop (luster/web/elmish.gleam) and command system. 14 | // * ELM loop callback. 15 | // * Command system for side-effects. 16 | // * Decoder/Encoder callbacks. 17 | // This would let me simplify the socket models. 18 | 19 | // TODO: Consider transition to Lustre framework. 20 | 21 | // TODO: Add open/close click to end game screen. 22 | 23 | pub fn main() -> Nil { 24 | // Start the persistence store but clean before starting. 25 | let Nil = store.start() 26 | let Nil = store.clean() 27 | 28 | // Start the live sessions registry. 29 | let assert Ok(store) = session.start() 30 | 31 | // Start the pubsub system. 32 | let assert Ok(pubsub) = pubsub.start() 33 | 34 | // Define middleware pipeline for server and start it. 35 | let request_pipeline = fn(request: request.Request(mist.Connection)) -> response.Response( 36 | mist.ResponseData, 37 | ) { 38 | web.router(request, store, pubsub) 39 | } 40 | 41 | let assert Ok(_server) = 42 | mist.new(request_pipeline) 43 | |> mist.port(4444) 44 | |> mist.start_https( 45 | certfile: env("LUSTER_CERT"), 46 | keyfile: env("LUSTER_KEY"), 47 | ) 48 | 49 | process.sleep_forever() 50 | } 51 | 52 | fn env(key: String) -> String { 53 | case envoy.get(key) { 54 | Ok(value) -> value 55 | Error(Nil) -> panic as "unable to find ENV" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/luster/web/home/view.gleam: -------------------------------------------------------------------------------- 1 | import gleam/int 2 | import gleam/list 3 | import nakai/html 4 | import nakai/html/attrs 5 | import luster/line_poker/session 6 | import luster/line_poker/store 7 | 8 | // --- Elm-ish architecture with a Model and View callback --- // 9 | 10 | pub type Model { 11 | Model(List(#(Int, String))) 12 | } 13 | 14 | type GameMode { 15 | PlayerVsPlayer 16 | PlayerVsComp 17 | CompVsComp 18 | } 19 | 20 | pub fn init(store: session.Registry) -> Model { 21 | let static_records = 22 | store.all() 23 | |> list.map(fn(record) { #(record.id, record.name) }) 24 | 25 | let live_records = 26 | session.all_sessions(store) 27 | |> list.map(session.get_record) 28 | |> list.map(fn(record) { #(record.id, record.name) }) 29 | 30 | let render_records = 31 | [static_records, live_records] 32 | |> list.concat() 33 | |> list.sort(fn(left, right) { int.compare(left.0, right.0) }) 34 | 35 | Model(render_records) 36 | } 37 | 38 | pub fn view(model: Model) -> html.Node(a) { 39 | let Model(records) = model 40 | 41 | let cards = list.map(records, dashboard_card) 42 | html.section([attrs.class("lobby")], [ 43 | html.div([attrs.class("row center control-panel")], [ 44 | html.div([attrs.class("column evenly")], [ 45 | create_game_form(PlayerVsPlayer, "2P Game"), 46 | create_game_form(PlayerVsComp, "1P Game"), 47 | create_game_form(CompVsComp, "Comp Game"), 48 | ]), 49 | ]), 50 | html.div([attrs.class("games column wrap")], cards), 51 | ]) 52 | } 53 | 54 | // --- Helpers to build the view --- // 55 | 56 | fn create_game_form(mode: GameMode, text: String) -> html.Node(a) { 57 | html.form([attrs.method("post"), attrs.action("/battleline")], [ 58 | html.input([ 59 | attrs.type_("number"), 60 | attrs.name("quantity"), 61 | attrs.Attr(name: "min", value: "1"), 62 | attrs.Attr(name: "max", value: "100"), 63 | attrs.value("1"), 64 | ]), 65 | case mode { 66 | PlayerVsPlayer -> 67 | html.input([ 68 | attrs.type_("hidden"), 69 | attrs.name("PlayerVsPlayer"), 70 | attrs.value(""), 71 | ]) 72 | PlayerVsComp -> 73 | html.input([ 74 | attrs.type_("hidden"), 75 | attrs.name("PlayerVsComp"), 76 | attrs.value(""), 77 | ]) 78 | CompVsComp -> 79 | html.input([ 80 | attrs.type_("hidden"), 81 | attrs.name("CompVsComp"), 82 | attrs.value(""), 83 | ]) 84 | }, 85 | html.input([attrs.type_("submit"), attrs.value(text)]), 86 | ]) 87 | } 88 | 89 | fn dashboard_card(record: #(Int, String)) -> html.Node(a) { 90 | let #(id, name) = record 91 | 92 | let id = int.to_string(id) 93 | html.div([attrs.id(id), attrs.class("dashboard-card")], [ 94 | html.div([attrs.class("link")], [ 95 | link("https://localhost:4444/battleline/" <> id, name), 96 | ]), 97 | html.div([attrs.class("preview")], [frame(id)]), 98 | ]) 99 | } 100 | 101 | fn frame(id: String) -> html.Node(a) { 102 | html.iframe( 103 | [ 104 | attrs.width("100%"), 105 | attrs.height("100%"), 106 | attrs.Attr(name: "frameBorder", value: "0"), 107 | attrs.src("https://localhost:4444/battleline/" <> id), 108 | ], 109 | [], 110 | ) 111 | } 112 | 113 | fn link(url: String, text: String) -> html.Node(a) { 114 | html.a_text([attrs.href(url)], text) 115 | } 116 | -------------------------------------------------------------------------------- /manifest.toml: -------------------------------------------------------------------------------- 1 | # This file was generated by Gleam 2 | # You typically do not need to edit this file 3 | 4 | packages = [ 5 | { name = "chip", version = "0.2.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_otp", "gleam_erlang"], otp_app = "chip", source = "hex", outer_checksum = "493D756616D76FB31C043DB382BAC7D895736D8B69BB3C97803FED087D446D35" }, 6 | { name = "cubdb", version = "2.0.2", build_tools = ["mix"], requirements = [], otp_app = "cubdb", source = "hex", outer_checksum = "C99CC8F9E6C4DEB98D16CCA5DED1928EDD22E48B4736B76E8A1A85367D7FE921" }, 7 | { name = "envoy", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "CFAACCCFC47654F7E8B75E614746ED924C65BD08B1DE21101548AC314A8B6A41" }, 8 | { name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" }, 9 | { name = "gleam_http", version = "3.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "8C07DF9DF8CC7F054C650839A51C30A7D3C26482AC241C899C1CEA86B22DBE51" }, 10 | { name = "gleam_json", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "8B197DD5D578EA6AC2C0D4BDC634C71A5BCA8E7DB5F47091C263ECB411A60DF3" }, 11 | { name = "gleam_otp", version = "0.10.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_erlang"], otp_app = "gleam_otp", source = "hex", outer_checksum = "0B04FE915ACECE539B317F9652CAADBBC0F000184D586AAAF2D94C100945D72B" }, 12 | { name = "gleam_stdlib", version = "0.36.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "C0D14D807FEC6F8A08A7C9EF8DFDE6AE5C10E40E21325B2B29365965D82EB3D4" }, 13 | { name = "gleeunit", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "D364C87AFEB26BDB4FB8A5ABDE67D635DC9FA52D6AB68416044C35B096C6882D" }, 14 | { name = "glisten", version = "0.11.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "glisten", source = "hex", outer_checksum = "73BC09C8487C2FFC0963BFAB33ED2F0D636FDFA43B966E65C1251CBAB8458099" }, 15 | { name = "mist", version = "0.17.0", build_tools = ["gleam"], requirements = ["glisten", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib"], otp_app = "mist", source = "hex", outer_checksum = "DA8ACEE52C1E4892A75181B3166A4876D8CBC69D555E4770250BC84C80F75524" }, 16 | { name = "nakai", version = "0.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "nakai", source = "hex", outer_checksum = "F6FFED9EF4B0E14C7A09B2FB87B42D3A93EFE024FD0299C11F041E92321163A6" }, 17 | { name = "thoas", version = "0.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "4918D50026C073C4AB1388437132C77A6F6F7C8AC43C60C13758CC0ADCE2134E" }, 18 | ] 19 | 20 | [requirements] 21 | chip = { version = "~> 0.2.1" } 22 | cubdb = { version = "~> 2.0" } 23 | envoy = { version = "~> 1.0" } 24 | gleam_erlang = { version = "~> 0.24" } 25 | gleam_http = { version = "~> 3.5" } 26 | gleam_json = { version = "~> 1.0" } 27 | gleam_otp = { version = "~> 0.9" } 28 | gleam_stdlib = { version = "~> 0.34" } 29 | gleeunit = { version = "~> 1.0" } 30 | mist = { version = "~> 0.15" } 31 | nakai = { version = "~> 0.9.0" } 32 | -------------------------------------------------------------------------------- /src/luster/line_poker/computer_player.gleam: -------------------------------------------------------------------------------- 1 | import gleam/erlang/process.{type Subject, Normal} 2 | import gleam/float 3 | import gleam/function.{identity} 4 | import gleam/int 5 | import gleam/io 6 | import gleam/list 7 | import gleam/option.{None} 8 | import gleam/otp/actor.{ 9 | type InitResult, type Next, type StartError, Continue, Ready, Spec, Stop, 10 | } 11 | import gleam/result.{try} 12 | import luster/line_poker/game as g 13 | import luster/line_poker/session 14 | import luster/pubsub.{type PubSub} 15 | import luster/web/line_poker/socket 16 | import luster/web/line_poker/view as tea 17 | 18 | pub type Message { 19 | AssessMove 20 | Halt 21 | } 22 | 23 | type State { 24 | State( 25 | self: Subject(Message), 26 | session_id: Int, 27 | player: g.Player, 28 | session: Subject(session.Message), 29 | pubsub: PubSub(Int, socket.Message), 30 | ) 31 | } 32 | 33 | pub fn start( 34 | player: g.Player, 35 | session: Subject(session.Message), 36 | pubsub: PubSub(Int, socket.Message), 37 | ) -> Result(Subject(Message), StartError) { 38 | actor.start_spec(Spec( 39 | init: fn() { handle_init(player, session, pubsub) }, 40 | init_timeout: 10, 41 | loop: handle_message, 42 | )) 43 | } 44 | 45 | fn handle_init( 46 | player: g.Player, 47 | session: Subject(session.Message), 48 | pubsub: PubSub(Int, socket.Message), 49 | ) -> InitResult(State, Message) { 50 | let self = process.new_subject() 51 | 52 | let record = session.get_record(session) 53 | 54 | process.send(self, AssessMove) 55 | 56 | let monitor = 57 | session 58 | |> process.subject_owner() 59 | |> process.monitor_process() 60 | 61 | Ready( 62 | State(self, record.id, player, session, pubsub), 63 | process.new_selector() 64 | |> process.selecting(self, identity) 65 | |> process.selecting_process_down(monitor, fn(_down) { Halt }), 66 | ) 67 | } 68 | 69 | fn handle_message(message: Message, state: State) -> Next(Message, State) { 70 | case message { 71 | AssessMove -> { 72 | let _ = { 73 | use record <- try(session.fetch_record(state.session)) 74 | use message <- try(assess_move(record.gamestate, state.player)) 75 | broadcast_message(state, message) 76 | Ok(Nil) 77 | } 78 | 79 | let _timer = process.send_after(state.self, between(150, 150), AssessMove) 80 | 81 | Continue(state, None) 82 | } 83 | 84 | Halt -> { 85 | let id = int.to_string(state.session_id) 86 | let player = case state.player { 87 | g.Player1 -> "player 1" 88 | g.Player2 -> "player 2" 89 | } 90 | 91 | io.println("halting computer " <> player <> " for session " <> id) 92 | Stop(Normal) 93 | } 94 | } 95 | } 96 | 97 | fn broadcast_message(state: State, message: g.Message) -> Nil { 98 | case session.next(state.session, message) { 99 | Ok(gamestate) -> { 100 | let message = tea.UpdateGame(gamestate) 101 | pubsub.broadcast(state.pubsub, state.session_id, socket.Update(message)) 102 | } 103 | 104 | Error(_) -> { 105 | Nil 106 | } 107 | } 108 | } 109 | 110 | fn assess_move( 111 | gamestate: g.GameState, 112 | player: g.Player, 113 | ) -> Result(g.Message, Nil) { 114 | let hand = g.player_hand(gamestate, player) 115 | let slots = g.available_plays(gamestate, player) 116 | let phase = g.current_phase(gamestate) 117 | 118 | case phase, list.length(hand), g.current_player(gamestate) { 119 | g.End, _size, _player -> { 120 | Error(Nil) 121 | } 122 | 123 | _phase, size, current if size == g.max_hand_size && current == player -> { 124 | Ok(play_card(player, slots, hand)) 125 | } 126 | 127 | _phase, size, current if size == g.max_hand_size && current != player -> { 128 | Error(Nil) 129 | } 130 | 131 | _phase, _size, current if current != player -> { 132 | Ok(draw_card(player)) 133 | } 134 | 135 | _phase, 0, _player -> { 136 | Ok(draw_card(player)) 137 | } 138 | 139 | _phase, _size, player -> { 140 | let assert Ok(move) = 141 | [play_card(player, slots, hand), draw_card(player)] 142 | |> list.shuffle() 143 | |> list.first() 144 | 145 | Ok(move) 146 | } 147 | } 148 | } 149 | 150 | fn play_card(player, slots, hand) -> g.Message { 151 | let assert Ok(slot) = 152 | slots 153 | |> list.shuffle() 154 | |> list.first() 155 | let assert Ok(card) = 156 | hand 157 | |> list.shuffle() 158 | |> list.first() 159 | g.PlayCard(player, slot, card) 160 | } 161 | 162 | fn draw_card(player) -> g.Message { 163 | g.DrawCard(player) 164 | } 165 | 166 | fn between(start: Int, end: Int) -> Int { 167 | let period = int.to_float(end - start) 168 | let random = 169 | period 170 | |> float.multiply(float.random()) 171 | |> float.round() 172 | 173 | random + start 174 | } 175 | -------------------------------------------------------------------------------- /src/luster/line_poker/session.gleam: -------------------------------------------------------------------------------- 1 | import chip 2 | import gleam/erlang/process.{type Subject, Normal} 3 | import gleam/function.{identity, tap} 4 | import gleam/list 5 | import gleam/option.{None} 6 | import gleam/otp/actor.{ 7 | type InitResult, type Next, type StartError, Continue, Ready, Spec, Stop, 8 | } 9 | import gleam/string 10 | import luster/line_poker/game as g 11 | import luster/line_poker/store 12 | import luster/web/line_poker/view as tea 13 | import nakai 14 | 15 | // We treat this chip registry instance as registry so it has a mix of session subject 16 | // operations as well as a CRUD-like API for retrieving its content (Record). 17 | 18 | pub type Registry = 19 | process.Subject(chip.Message(Int, Message)) 20 | 21 | pub type Record { 22 | Record(id: Int, name: String, gamestate: g.GameState) 23 | } 24 | 25 | pub fn start() -> Result(Registry, StartError) { 26 | chip.start() 27 | } 28 | 29 | pub fn new_session(registry: Registry) -> Result(Subject(Message), StartError) { 30 | let session_id = unique_integer([Monotonic, Positive]) 31 | 32 | chip.register_as(registry, session_id, fn() { 33 | actor.start_spec(Spec( 34 | init: fn() { handle_init(session_id) }, 35 | init_timeout: 10, 36 | loop: handle_message, 37 | )) 38 | }) 39 | } 40 | 41 | pub fn all_sessions(registry: Registry) -> List(Subject(Message)) { 42 | chip.all(registry) 43 | } 44 | 45 | pub fn get_session(registry: Registry, id: Int) -> Result(Subject(Message), Nil) { 46 | case chip.lookup(registry, id) { 47 | [] -> Error(Nil) 48 | [subject] -> Ok(subject) 49 | _subjects -> panic as "multiple subjects with same id" 50 | } 51 | } 52 | 53 | pub fn get_record(subject: Subject(Message)) -> Record { 54 | process.call(subject, GetRecord(_), 100) 55 | } 56 | 57 | pub fn fetch_record(subject: Subject(Message)) -> Result(Record, Nil) { 58 | case process.try_call(subject, GetRecord(_), 100) { 59 | Ok(record) -> Ok(record) 60 | Error(_call_error) -> Error(Nil) 61 | } 62 | } 63 | 64 | pub fn set_gamestate(subject: Subject(Message), gamestate: g.GameState) -> Nil { 65 | process.call(subject, SetGameState(_, gamestate), 100) 66 | } 67 | 68 | pub fn next( 69 | session: Subject(Message), 70 | message: g.Message, 71 | ) -> Result(g.GameState, g.Errors) { 72 | actor.call(session, Next(_, message), 100) 73 | } 74 | 75 | // Session actor API and callbacks 76 | 77 | type State { 78 | State(self: Subject(Message), record: Record) 79 | } 80 | 81 | pub opaque type Message { 82 | GetRecord(caller: Subject(Record)) 83 | SetGameState(caller: Subject(Nil), gamestate: g.GameState) 84 | Next(caller: Subject(Result(g.GameState, g.Errors)), g.Message) 85 | Halt 86 | } 87 | 88 | fn handle_init(session_id: Int) -> InitResult(State, Message) { 89 | let self = process.new_subject() 90 | 91 | let state = 92 | State( 93 | self: self, 94 | record: Record(id: session_id, name: generate_name(), gamestate: g.new()), 95 | ) 96 | 97 | let selector = 98 | process.new_selector() 99 | |> process.selecting(self, identity) 100 | 101 | Ready(state, selector) 102 | } 103 | 104 | fn handle_message(message: Message, state: State) -> Next(Message, State) { 105 | case message { 106 | GetRecord(caller) -> { 107 | process.send(caller, state.record) 108 | Continue(state, None) 109 | } 110 | 111 | SetGameState(caller, gamestate) -> { 112 | // If game ended, queue up a message to halt actor 113 | case g.current_phase(gamestate) { 114 | g.End -> process.send(state.self, Halt) 115 | _other -> Nil 116 | } 117 | 118 | // Set new gamestate 119 | let record = Record(..state.record, gamestate: gamestate) 120 | let state = State(..state, record: record) 121 | 122 | process.send(caller, Nil) 123 | 124 | Continue(state, None) 125 | } 126 | 127 | Next(caller, message) -> { 128 | let result = 129 | state.record.gamestate 130 | |> g.next(message) 131 | |> tap(process.send(caller, _)) 132 | 133 | let gamestate = case result { 134 | Ok(gamestate) -> gamestate 135 | Error(_) -> state.record.gamestate 136 | } 137 | 138 | case g.current_phase(gamestate) { 139 | g.End -> process.send(state.self, Halt) 140 | _phase -> Nil 141 | } 142 | 143 | let record = Record(..state.record, gamestate: gamestate) 144 | let state = State(..state, record: record) 145 | 146 | Continue(state, None) 147 | } 148 | 149 | Halt -> { 150 | let record = 151 | store.Record( 152 | id: state.record.id, 153 | name: state.record.name, 154 | document: tea.init(state.record.gamestate) 155 | |> tea.view() 156 | |> nakai.to_inline_string(), 157 | ) 158 | 159 | store.put(state.record.id, record) 160 | 161 | Stop(Normal) 162 | } 163 | } 164 | } 165 | 166 | type Param { 167 | Monotonic 168 | Positive 169 | } 170 | 171 | @external(erlang, "erlang", "unique_integer") 172 | fn unique_integer(params: List(Param)) -> Int 173 | 174 | const adjectives = [ 175 | "salty", "brief", "noble", "glorious", "respectful", "tainted", "measurable", 176 | "constant", "fake", "lighting", "cool", "sparkling", "painful", "stealthy", 177 | "mighty", "activated", "lit", "memorable", "pink", "usual", 178 | ] 179 | 180 | const subjects = [ 181 | "poker", "party", "danceoff", "bakeoff", "marathon", "club", "game", "match", 182 | "rounds", "battleline", "duel", "dungeon", "siege", "encounter", "trap", 183 | "gleam", "routine", "thunder", "odyssey", "actor", "BEAM", "mockery", 184 | ] 185 | 186 | fn generate_name() -> String { 187 | let assert Ok(adjective) = 188 | adjectives 189 | |> list.shuffle() 190 | |> list.first() 191 | 192 | let assert Ok(subject) = 193 | subjects 194 | |> list.shuffle() 195 | |> list.first() 196 | 197 | string.capitalise(adjective) <> " " <> string.capitalise(subject) 198 | } 199 | -------------------------------------------------------------------------------- /src/luster/web.gleam: -------------------------------------------------------------------------------- 1 | import gleam/bit_array 2 | import gleam/bytes_builder 3 | import gleam/erlang 4 | import gleam/http 5 | import gleam/http/request 6 | import gleam/http/response 7 | import gleam/int 8 | import gleam/iterator 9 | import gleam/string 10 | import gleam/uri 11 | import luster/line_poker/computer_player 12 | import luster/line_poker/game as g 13 | import luster/line_poker/session 14 | import luster/line_poker/store 15 | import luster/pubsub 16 | import luster/web/home/view as tea_home 17 | import luster/web/layout 18 | import luster/web/line_poker/socket 19 | import luster/web/line_poker/view as tea_game 20 | import mist 21 | import nakai 22 | import nakai/html 23 | 24 | pub fn router( 25 | request: request.Request(mist.Connection), 26 | store: session.Registry, 27 | pubsub: pubsub.PubSub(Int, socket.Message), 28 | ) -> response.Response(mist.ResponseData) { 29 | case request.method, request.path_segments(request) { 30 | http.Get, [] -> { 31 | tea_home.init(store) 32 | |> tea_home.view() 33 | |> render(with: fn(body) { layout.view("", False, body) }) 34 | } 35 | 36 | http.Post, ["battleline"] -> { 37 | request 38 | |> process_form() 39 | |> create_games(store, pubsub) 40 | 41 | redirect("/") 42 | } 43 | 44 | http.Get, ["battleline", session_id] -> { 45 | let assert Ok(id) = int.parse(session_id) 46 | 47 | case session.get_session(store, id) { 48 | Ok(subject) -> { 49 | let record = session.get_record(subject) 50 | tea_game.init(record.gamestate) 51 | |> tea_game.view() 52 | |> render(with: fn(body) { layout.view(session_id, True, body) }) 53 | } 54 | 55 | Error(Nil) -> { 56 | case store.get(id) { 57 | Ok(record) -> 58 | html.UnsafeInlineHtml(record.document) 59 | |> render(with: fn(body) { layout.view(session_id, False, body) }) 60 | 61 | Error(Nil) -> redirect("/") 62 | } 63 | } 64 | } 65 | } 66 | 67 | http.Get, ["events", session_id] -> { 68 | let assert Ok(id) = int.parse(session_id) 69 | 70 | case session.get_session(store, id) { 71 | Ok(subject) -> socket.start(request, subject, pubsub) 72 | Error(Nil) -> not_found() 73 | } 74 | } 75 | 76 | http.Get, ["assets", ..] -> { 77 | serve_assets(request) 78 | } 79 | 80 | _, _ -> { 81 | not_found() 82 | } 83 | } 84 | } 85 | 86 | fn create_games( 87 | params: List(#(String, String)), 88 | store: session.Registry, 89 | pubsub: pubsub.PubSub(Int, socket.Message), 90 | ) { 91 | let #(quantity, rest) = case params { 92 | [#("quantity", qty), ..rest] -> 93 | case int.parse(qty) { 94 | Ok(qty) -> #(qty, rest) 95 | Error(_) -> #(1, rest) 96 | } 97 | _other -> #(1, []) 98 | } 99 | 100 | let game_mode = case rest { 101 | [#("PlayerVsPlayer", _)] -> fn(_) { 102 | let assert Ok(_) = session.new_session(store) 103 | Nil 104 | } 105 | [#("PlayerVsComp", _)] -> fn(_) { 106 | let assert Ok(subject) = session.new_session(store) 107 | let assert Ok(_comp_2) = computer_player.start(g.Player2, subject, pubsub) 108 | Nil 109 | } 110 | [#("CompVsComp", _)] | _other -> fn(_) { 111 | let assert Ok(subject) = session.new_session(store) 112 | let assert Ok(_comp_1) = computer_player.start(g.Player1, subject, pubsub) 113 | let assert Ok(_comp_2) = computer_player.start(g.Player2, subject, pubsub) 114 | Nil 115 | } 116 | } 117 | 118 | iterator.range(from: 1, to: quantity) 119 | |> iterator.each(game_mode) 120 | } 121 | 122 | // https://www.iana.org/assignments/media-types/media-types.xhtml 123 | type MIME { 124 | HTML 125 | CSS 126 | JavaScript 127 | Favicon 128 | TextPlain 129 | } 130 | 131 | fn render( 132 | body: html.Node(a), 133 | with layout: fn(html.Node(a)) -> html.Node(a), 134 | ) -> response.Response(mist.ResponseData) { 135 | let document = 136 | layout(body) 137 | |> nakai.to_string_builder() 138 | |> bytes_builder.from_string_builder() 139 | |> mist.Bytes 140 | 141 | response.new(200) 142 | |> response.prepend_header("cache-control", "no-store, no-cache, max-age=0") 143 | |> response.prepend_header("content-type", content_type(HTML)) 144 | |> response.set_body(document) 145 | } 146 | 147 | fn redirect(path: String) -> response.Response(mist.ResponseData) { 148 | response.new(303) 149 | |> response.prepend_header("cache-control", "no-store, no-cache, max-age=0") 150 | |> response.prepend_header("location", path) 151 | |> response.set_body(mist.Bytes(bytes_builder.new())) 152 | } 153 | 154 | fn not_found() -> response.Response(mist.ResponseData) { 155 | response.new(404) 156 | |> response.prepend_header("cache-control", "no-store, no-cache, max-age=0") 157 | |> response.prepend_header("content-type", content_type(TextPlain)) 158 | |> response.set_body(mist.Bytes(bytes_builder.from_string("Not found"))) 159 | } 160 | 161 | fn serve_assets( 162 | request: request.Request(mist.Connection), 163 | ) -> response.Response(mist.ResponseData) { 164 | let assert Ok(root) = erlang.priv_directory("luster") 165 | let assert asset = string.join([root, request.path], "") 166 | 167 | case read_file(asset) { 168 | Ok(asset) -> { 169 | let mime = extract_mime(request.path) 170 | 171 | response.new(200) 172 | |> response.prepend_header("content-type", content_type(mime)) 173 | |> response.set_body(mist.Bytes(asset)) 174 | } 175 | 176 | _ -> { 177 | not_found() 178 | } 179 | } 180 | } 181 | 182 | fn process_form( 183 | request: request.Request(mist.Connection), 184 | ) -> List(#(String, String)) { 185 | let assert Ok(request) = mist.read_body(request, 10_000) 186 | let assert Ok(value) = bit_array.to_string(request.body) 187 | let assert Ok(params) = uri.parse_query(value) 188 | params 189 | } 190 | 191 | fn content_type(mime: MIME) -> String { 192 | case mime { 193 | HTML -> "text/html; charset=utf-8" 194 | CSS -> "text/css" 195 | JavaScript -> "text/javascript" 196 | Favicon -> "image/x-icon" 197 | TextPlain -> "text/plain; charset=utf-8" 198 | } 199 | } 200 | 201 | fn extract_mime(path: String) -> MIME { 202 | let ext = 203 | path 204 | |> string.lowercase() 205 | |> extension() 206 | 207 | case ext { 208 | ".css" -> CSS 209 | ".ico" -> Favicon 210 | ".js" -> JavaScript 211 | _ -> panic as "unable to identify media type" 212 | } 213 | } 214 | 215 | @external(erlang, "file", "read_file") 216 | fn read_file(path: String) -> Result(bytes_builder.BytesBuilder, error) 217 | 218 | @external(erlang, "filename", "extension") 219 | fn extension(path: String) -> String 220 | -------------------------------------------------------------------------------- /src/luster/web/line_poker/socket.gleam: -------------------------------------------------------------------------------- 1 | import gleam/bit_array 2 | import gleam/erlang/process.{type Selector, type Subject, Normal} 3 | import gleam/function.{identity, tap} 4 | import gleam/http/request.{type Request} 5 | import gleam/http/response.{type Response} 6 | import gleam/int 7 | import gleam/io 8 | import gleam/option.{type Option, None, Some} 9 | import gleam/otp/actor.{type Next, Continue, Stop} 10 | import gleam/result.{try} 11 | import luster/line_poker/game as g 12 | import luster/line_poker/session 13 | import luster/pubsub.{type PubSub} 14 | import luster/web/line_poker/view as tea 15 | import mist.{ 16 | type Connection, type ResponseData, type WebsocketConnection, 17 | type WebsocketMessage, Binary, Closed, Custom, Shutdown, Text, 18 | } 19 | import nakai 20 | 21 | pub type Message { 22 | Update(tea.Message) 23 | PrepareHalt 24 | Halt 25 | } 26 | 27 | pub type Action { 28 | Play(g.Message) 29 | Select(tea.Message) 30 | } 31 | 32 | pub opaque type State { 33 | State( 34 | self: Subject(Message), 35 | session_id: Int, 36 | session: Subject(session.Message), 37 | pubsub: PubSub(Int, Message), 38 | model: tea.Model, 39 | ) 40 | } 41 | 42 | pub fn start( 43 | request: Request(Connection), 44 | session: Subject(session.Message), 45 | pubsub: PubSub(Int, Message), 46 | ) -> Response(ResponseData) { 47 | mist.websocket( 48 | request: request, 49 | on_init: build_init(_, session, pubsub), 50 | on_close: on_close, 51 | handler: handle_message, 52 | ) 53 | } 54 | 55 | fn build_init( 56 | _conn: WebsocketConnection, 57 | session: Subject(session.Message), 58 | pubsub: PubSub(Int, Message), 59 | ) -> #(State, Option(Selector(Message))) { 60 | // Create an internal subject to send messages to itself 61 | let self = process.new_subject() 62 | 63 | // Retrieve data from the session 64 | let record = session.get_record(session) 65 | 66 | // Register the subject to broacast messages across sockets 67 | pubsub.register(pubsub, record.id, self) 68 | 69 | // Initialize a live TEA-like model for the socket 70 | let model = tea.init(record.gamestate) 71 | 72 | // Monitor the session process so we can track if it goes down 73 | let monitor = 74 | session 75 | |> process.subject_owner() 76 | |> process.monitor_process() 77 | 78 | // Initialize state and enable selectors for self ref and the monitor ref 79 | #( 80 | State(self, record.id, session, pubsub, model), 81 | Some( 82 | process.new_selector() 83 | |> process.selecting(self, identity) 84 | |> process.selecting_process_down(monitor, fn(_down) { PrepareHalt }), 85 | ), 86 | ) 87 | } 88 | 89 | fn on_close(state: State) -> Nil { 90 | let session_id = int.to_string(state.session_id) 91 | io.println("closing socket connection for session: " <> session_id) 92 | Nil 93 | } 94 | 95 | fn handle_message( 96 | state: State, 97 | conn: WebsocketConnection, 98 | message: WebsocketMessage(Message), 99 | ) -> Next(a, State) { 100 | case message { 101 | Binary(bits) -> { 102 | case parse_message(bits) { 103 | Ok(action) -> { 104 | state.session 105 | |> build_message(action) 106 | |> broadcast_message(state, _) 107 | 108 | Continue(state, None) 109 | } 110 | 111 | Error(Nil) -> { 112 | io.print("out of bound message: ") 113 | io.debug(bits) 114 | Continue(state, None) 115 | } 116 | } 117 | } 118 | 119 | Custom(Update(message)) -> { 120 | let model = tea.update(state.model, message) 121 | 122 | model 123 | |> tea.view() 124 | |> nakai.to_inline_string() 125 | |> tap(mist.send_text_frame(conn, _)) 126 | 127 | Continue(State(..state, model: model), None) 128 | } 129 | 130 | Custom(PrepareHalt) -> { 131 | // At this point, last update messages are still being broadcasted/enqueued in 132 | // the socket mailbox. This works as a kind of buffer to let them being 133 | // processed before shutdown. 134 | let id = int.to_string(state.session_id) 135 | io.println("preparing to halt socket for session " <> id) 136 | process.send_after(state.self, 5000, Halt) 137 | Continue(state, None) 138 | } 139 | 140 | Custom(Halt) -> { 141 | // And shutdown for real. 142 | Stop(Normal) 143 | } 144 | 145 | Text(message) -> { 146 | io.println("out of bound message: " <> message) 147 | Continue(state, None) 148 | } 149 | 150 | Closed | Shutdown -> { 151 | Stop(Normal) 152 | } 153 | } 154 | } 155 | 156 | fn build_message( 157 | session: Subject(session.Message), 158 | action: Action, 159 | ) -> tea.Message { 160 | case action { 161 | Play(message) -> { 162 | case session.next(session, message) { 163 | Ok(gamestate) -> tea.UpdateGame(gamestate) 164 | Error(error) -> tea.Alert(error) 165 | } 166 | } 167 | 168 | Select(message) -> { 169 | message 170 | } 171 | } 172 | } 173 | 174 | fn broadcast_message(state: State, message: tea.Message) -> Nil { 175 | case message { 176 | tea.UpdateGame(_) as message -> { 177 | // When gamestate is updated broadcast it to all sockets 178 | pubsub.broadcast(state.pubsub, state.session_id, Update(message)) 179 | } 180 | 181 | message -> { 182 | // When UI is updated only this socket needs to know 183 | process.send(state.self, Update(message)) 184 | } 185 | } 186 | } 187 | 188 | // --- Decoders for incoming messages --- // 189 | 190 | fn parse_message(bits: BitArray) -> Result(Action, Nil) { 191 | case bits { 192 | <<"draw-card":utf8, rest:bytes>> -> { 193 | use #(player) <- try(decode_draw_card(rest)) 194 | Ok(Play(g.DrawCard(player))) 195 | } 196 | 197 | <<"play-card":utf8, rest:bytes>> -> { 198 | use #(player, slot, card) <- try(decode_play_card(rest)) 199 | Ok(Play(g.PlayCard(player, slot, card))) 200 | } 201 | 202 | <<"select-card":utf8, rest:bytes>> -> { 203 | use #(card) <- try(decode_select_card(rest)) 204 | Ok(Select(tea.SelectCard(card))) 205 | } 206 | 207 | <<"popup-toggle":utf8, _rest:bytes>> -> { 208 | Ok(Select(tea.ToggleScoring)) 209 | } 210 | 211 | _other -> { 212 | Error(Nil) 213 | } 214 | } 215 | } 216 | 217 | fn decode_draw_card(bits: BitArray) -> Result(#(g.Player), Nil) { 218 | case bits { 219 | <> -> { 220 | use player <- try(decode_player(player)) 221 | Ok(#(player)) 222 | } 223 | 224 | _other -> Error(Nil) 225 | } 226 | } 227 | 228 | fn decode_play_card(bits: BitArray) -> Result(#(g.Player, g.Slot, g.Card), Nil) { 229 | case bits { 230 | << 231 | player:bytes-size(2), 232 | slot:bytes-size(1), 233 | suit:bytes-size(3), 234 | rank:bytes-size(2), 235 | >> -> { 236 | use player <- try(decode_player(player)) 237 | use slot <- try(decode_slot(slot)) 238 | use suit <- try(decode_suit(suit)) 239 | use rank <- try(decode_rank(rank)) 240 | Ok(#(player, slot, g.Card(rank, suit))) 241 | } 242 | 243 | _other -> Error(Nil) 244 | } 245 | } 246 | 247 | fn decode_select_card(bits: BitArray) -> Result(#(g.Card), Nil) { 248 | case bits { 249 | <> -> { 250 | use suit <- try(decode_suit(suit)) 251 | use rank <- try(decode_rank(rank)) 252 | Ok(#(g.Card(rank, suit))) 253 | } 254 | 255 | _other -> Error(Nil) 256 | } 257 | } 258 | 259 | fn decode_player(bits: BitArray) -> Result(g.Player, Nil) { 260 | case bits { 261 | <<"p1":utf8>> -> Ok(g.Player1) 262 | <<"p2":utf8>> -> Ok(g.Player2) 263 | _ -> Error(Nil) 264 | } 265 | } 266 | 267 | fn decode_slot(bits: BitArray) -> Result(g.Slot, Nil) { 268 | case bits { 269 | <<"1":utf8>> -> Ok(g.Slot1) 270 | <<"2":utf8>> -> Ok(g.Slot2) 271 | <<"3":utf8>> -> Ok(g.Slot3) 272 | <<"4":utf8>> -> Ok(g.Slot4) 273 | <<"5":utf8>> -> Ok(g.Slot5) 274 | <<"6":utf8>> -> Ok(g.Slot6) 275 | <<"7":utf8>> -> Ok(g.Slot7) 276 | <<"8":utf8>> -> Ok(g.Slot8) 277 | <<"9":utf8>> -> Ok(g.Slot9) 278 | _ -> Error(Nil) 279 | } 280 | } 281 | 282 | fn decode_suit(bits: BitArray) -> Result(g.Suit, Nil) { 283 | case bits { 284 | <<"♠":utf8>> -> Ok(g.Spade) 285 | <<"♥":utf8>> -> Ok(g.Heart) 286 | <<"♦":utf8>> -> Ok(g.Diamond) 287 | <<"♣":utf8>> -> Ok(g.Club) 288 | _ -> Error(Nil) 289 | } 290 | } 291 | 292 | fn decode_rank(bits: BitArray) -> Result(Int, Nil) { 293 | use string <- try(bit_array.to_string(bits)) 294 | use int <- try(int.parse(string)) 295 | Ok(int) 296 | } 297 | -------------------------------------------------------------------------------- /priv/assets/styles.css: -------------------------------------------------------------------------------- 1 | /* Page level styles */ 2 | 3 | *[data-event]>* { 4 | /* This is what makes the click through elements work */ 5 | pointer-events: none; 6 | } 7 | 8 | 9 | :root { 10 | --gold: #ac9100; 11 | --shiny-gold: gold; 12 | --light: #ffaff3; 13 | --dark: #2f2f2f; 14 | --light: #f1f3e5; 15 | --dark: #456d69; 16 | --gold: #ac7f00; 17 | --shiny-gold: #d39704; 18 | --light: #ecede6; 19 | --dark: #5c4255; 20 | } 21 | 22 | body { 23 | margin: 0; 24 | padding: 0; 25 | background-color: var(--light); 26 | } 27 | 28 | /* Home page */ 29 | 30 | .lobby .control-panel { 31 | height: 5vh; 32 | } 33 | 34 | .lobby .dashboard-card { 35 | width: 25%; 36 | height: 25vh; 37 | padding-top: 1vh; 38 | padding-bottom: 1vh; 39 | font-size: 1.5em; 40 | text-align: center; 41 | font-family: sans-serif; 42 | } 43 | 44 | .lobby .dashboard-card .preview { 45 | height: 80%; 46 | padding: 1vh; 47 | } 48 | 49 | /* Game page */ 50 | 51 | .player-info { 52 | font-family: sans-serif; 53 | height: 10vh; 54 | font-size: 4vh; 55 | } 56 | 57 | .play { 58 | height: 90vh; 59 | } 60 | 61 | .board { 62 | background-color: var(--dark); 63 | border-radius: 2vh; 64 | padding: 2vh; 65 | } 66 | 67 | .popup { 68 | position: absolute; 69 | top: 0; 70 | width: 100%; 71 | height: 100vh; 72 | font-size: 5vh; 73 | } 74 | 75 | .final-score { 76 | border-radius: 0.5vh; 77 | text-align: center; 78 | } 79 | 80 | .final-score h1, 81 | .final-score h2, 82 | .final-score h3 { 83 | margin: 0; 84 | } 85 | 86 | .final-score .cheatsheet { 87 | font-size: 1.5vh; 88 | width: 25vh; 89 | border-collapse: collapse; 90 | } 91 | 92 | .final-score .cheatsheet th, 93 | .final-score .cheatsheet td { 94 | border: 0.1vh solid var(--gray); 95 | } 96 | 97 | .final-score .cheatsheet tr { 98 | height: 3.5vh; 99 | text-align: center; 100 | } 101 | 102 | .final-score .score-winner { 103 | padding: 1vh 0 1vh 0; 104 | } 105 | 106 | .final-score .score-group { 107 | display: flex; 108 | font-size: 2.25vh; 109 | padding: 2vh 0 2vh 0; 110 | background-color: var(--light); 111 | } 112 | 113 | .final-score .score-group>div { 114 | padding: 0 2vh 0 2vh; 115 | } 116 | 117 | .final-score .score-group table { 118 | width: 100%; 119 | } 120 | 121 | .final-score .score-group table td { 122 | padding: 0.5vh 1.0vh; 123 | } 124 | 125 | .final-score .score-group table td:nth-child(1) { 126 | text-align: left; 127 | font-size: 3vh; 128 | } 129 | 130 | .final-score .score-group table td:nth-child(2) { 131 | text-align: right; 132 | font-size: 3.25vh; 133 | } 134 | 135 | .final-score .score-group table td div:nth-child(2) { 136 | text-align: left; 137 | font-size: 1.75vh; 138 | } 139 | 140 | .scores { 141 | display: flex; 142 | flex-direction: row; 143 | } 144 | 145 | /* TODO: Adapt exceeding 7.5 vh slots to mobile */ 146 | .scores>.score { 147 | height: 2vh; 148 | width: 7.5vh; 149 | color: var(--light); 150 | border-radius: 0.25vh; 151 | margin: 0.32vh; 152 | padding: 0.4vh; 153 | display: flex; 154 | flex-direction: row; 155 | flex-grow: 0; 156 | justify-content: space-evenly; 157 | align-items: center; 158 | font-size: 1.25vh; 159 | font-family: arial; 160 | } 161 | 162 | .scores.general>.score { 163 | justify-content: space-between; 164 | } 165 | 166 | .scores.totals>.score.player-1 { 167 | outline: 0.1vh solid var(--dark); 168 | background-color: var(--light); 169 | color: var(--dark); 170 | } 171 | 172 | .scores.totals>.score.player-2 { 173 | outline: 0.1vh solid var(--light); 174 | background-color: var(--dark); 175 | color: var(--light); 176 | } 177 | 178 | .scores>.score>.formation { 179 | color: var(--gold); 180 | } 181 | 182 | .scores>.score>.flank { 183 | color: var(--shiny-gold); 184 | } 185 | 186 | .slots { 187 | display: flex; 188 | flex-direction: row; 189 | } 190 | 191 | /* TODO: Adapt exceeding 7.5 vh slots to mobile */ 192 | .slots .slot { 193 | outline: 0.2vh solid var(--gold); 194 | position: relative; 195 | height: 18.25vh; 196 | width: 7.5vh; 197 | border-radius: 0.25vh; 198 | margin: 0.32vh; 199 | padding: 0.4vh; 200 | } 201 | 202 | /* Individual Elements */ 203 | 204 | /* TODO: Adapt exceeding 7.5 vh slots to mobile */ 205 | .draw-pile { 206 | position: relative; 207 | height: 10.5vh; 208 | width: 7.5vh; 209 | } 210 | 211 | .slot>.card.front { 212 | position: absolute; 213 | } 214 | 215 | .player-2.slot>.card:nth-child(1) { 216 | bottom: 0.5vh; 217 | } 218 | 219 | .player-2.slot>.card:nth-child(2) { 220 | bottom: 4.3vh; 221 | } 222 | 223 | .player-2.slot>.card:nth-child(3) { 224 | bottom: 8.1vh; 225 | } 226 | 227 | .player-1.slot>.card:nth-child(1) { 228 | top: 0.5vh; 229 | } 230 | 231 | .player-1.slot>.card:nth-child(2) { 232 | top: 4.3vh; 233 | } 234 | 235 | .player-1.slot>.card:nth-child(3) { 236 | top: 8.1vh; 237 | } 238 | 239 | /* TODO: Adapt exceeding 7.5 vh slots to mobile */ 240 | .card { 241 | /* standard size card 3.5in by 2.5in */ 242 | outline: 0.1vh solid #b5b5b5; 243 | height: 10.5vh; 244 | width: 7.5vh; 245 | border-radius: 0.75vh; 246 | } 247 | 248 | .red { 249 | color: #b01818; 250 | } 251 | 252 | .blue { 253 | color: #0072aa; 254 | } 255 | 256 | .green { 257 | color: #4d8848 258 | } 259 | 260 | .purple { 261 | color: #9c5aa2; 262 | } 263 | 264 | .card.front { 265 | position: relative; 266 | display: flex; 267 | justify-content: space-evenly; 268 | background-color: #ffffff; 269 | } 270 | 271 | .card.front p { 272 | text-align: center; 273 | margin: 0; 274 | } 275 | 276 | .card.front .graphic { 277 | font-size: 4.5vh; 278 | margin: 0; 279 | display: flex; 280 | align-items: center; 281 | } 282 | 283 | .card.front .corner { 284 | margin: 0.25em; 285 | font-size: 1.5vh; 286 | display: flex; 287 | } 288 | 289 | .card.front .corner.upper-left { 290 | flex-direction: column; 291 | } 292 | 293 | .card.front .bottom-right { 294 | flex-direction: column-reverse; 295 | } 296 | 297 | .card.back { 298 | box-shadow: inset 0px 0px 0px 0.5vh #ffffff; 299 | } 300 | 301 | /* took inspiration from https://codepen.io/apo11oCreed/pen/WNEgJE */ 302 | /* and https://gleam.run easter egg :sparkles: */ 303 | .sparkle { 304 | outline: 0.1vh solid #b5b5b5; 305 | color: #000000; 306 | background: 307 | repeating-linear-gradient(-30deg, 308 | #f9b1be 1em, 309 | #f9b1be 2em, 310 | #ffd596 2em, 311 | #ffd596 3em, 312 | #fdffab 3em, 313 | #fdffab 4em, 314 | #c8ffa7 4em, 315 | #c8ffa7 5em, 316 | #9ce7ff 5em, 317 | #9ce7ff 6em, 318 | #dcd2fb 6em, 319 | #dcd2fb 7em); 320 | } 321 | 322 | .draw-pile .card { 323 | position: absolute; 324 | margin: 0vh; 325 | } 326 | 327 | .draw-pile .card:nth-child(1) { 328 | bottom: 0vh; 329 | right: 0vh; 330 | } 331 | 332 | .draw-pile .card:nth-child(2) { 333 | bottom: 0.15vh; 334 | right: 0.15vh; 335 | } 336 | 337 | .draw-pile .card:nth-child(3) { 338 | bottom: 0.3vh; 339 | right: 0.3vh; 340 | } 341 | 342 | .draw-pile .card:nth-child(4) { 343 | bottom: 0.45vh; 344 | right: 0.45vh; 345 | } 346 | 347 | .draw-pile .card:nth-child(5) { 348 | bottom: 0.6vh; 349 | right: 0.6vh; 350 | } 351 | 352 | .draw-pile .card:nth-child(6) { 353 | bottom: 0.75vh; 354 | right: 0.75vh; 355 | } 356 | 357 | .draw-pile .card:nth-child(7) { 358 | bottom: 0.9vh; 359 | right: 0.9vh; 360 | } 361 | 362 | .draw-pile .card:nth-child(8) { 363 | bottom: 1.05vh; 364 | right: 1.05vh; 365 | } 366 | 367 | .draw-pile .card:nth-child(9) { 368 | bottom: 1.20vh; 369 | right: 1.20vh; 370 | } 371 | 372 | .draw-pile .card:nth-child(10) { 373 | bottom: 1.35vh; 374 | right: 1.35vh; 375 | } 376 | 377 | .draw-pile .card:nth-child(11) { 378 | bottom: 1.50vh; 379 | right: 1.50vh; 380 | } 381 | 382 | .draw-pile .card:nth-child(12) { 383 | bottom: 1.65vh; 384 | right: 1.65vh; 385 | } 386 | 387 | .draw-pile .card:nth-child(13) { 388 | bottom: 1.8vh; 389 | right: 1.8vh; 390 | } 391 | 392 | 393 | /* Try this */ 394 | .row { 395 | display: flex; 396 | flex-direction: column; 397 | } 398 | 399 | .column { 400 | display: flex; 401 | flex-direction: row; 402 | } 403 | 404 | .evenly { 405 | justify-content: space-evenly; 406 | } 407 | 408 | .center { 409 | justify-content: center; 410 | } 411 | 412 | .wrap { 413 | flex-wrap: wrap; 414 | } 415 | 416 | .s1 { 417 | flex: 1 1; 418 | } 419 | 420 | .s2 { 421 | flex: 2 2; 422 | } 423 | 424 | .s3 { 425 | flex: 3 3; 426 | } 427 | 428 | .s4 { 429 | flex: 4 4; 430 | } 431 | 432 | .s5 { 433 | flex: 5 5; 434 | } 435 | 436 | .s6 { 437 | flex: 6 6; 438 | } 439 | 440 | .s7 { 441 | flex: 7 7; 442 | } 443 | 444 | .s8 { 445 | flex: 8 8; 446 | } 447 | 448 | .s9 { 449 | flex: 9 9; 450 | } 451 | 452 | .s10 { 453 | flex: 10 10; 454 | } 455 | 456 | .s11 { 457 | flex: 11 11; 458 | } 459 | 460 | .s12 { 461 | flex: 12 12; 462 | } -------------------------------------------------------------------------------- /src/luster/line_poker/game.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dict.{type Dict} 2 | import gleam/int 3 | import gleam/list 4 | import gleam/option.{type Option, None, Some} 5 | import gleam/order.{type Order} 6 | import gleam/result.{try} 7 | 8 | pub const max_hand_size = 8 9 | 10 | pub const plays_per_turn = 4 11 | 12 | const ranks = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13] 13 | 14 | const suits = [Spade, Heart, Diamond, Club] 15 | 16 | const straight_flush = 23 17 | 18 | const three_of_a_kind = 17 19 | 20 | const straight = 11 21 | 22 | const flush = 5 23 | 24 | const pair = 3 25 | 26 | const highcard = 0 27 | 28 | const flank_bonus = 5 29 | 30 | pub type Message { 31 | DrawCard(player: Player) 32 | PlayCard(player: Player, slot: Slot, card: Card) 33 | } 34 | 35 | pub opaque type GameState { 36 | GameState( 37 | turn: Int, 38 | phase: Phase, 39 | board: Board, 40 | sequence: List(Player), 41 | total_score: TotalScore, 42 | ) 43 | } 44 | 45 | pub type Phase { 46 | Draw 47 | Play 48 | End 49 | } 50 | 51 | pub opaque type Board { 52 | Board( 53 | deck: List(Card), 54 | hands: Dict(Player, List(Card)), 55 | battleline: Line(Battle), 56 | ) 57 | } 58 | 59 | pub type Player { 60 | Player1 61 | Player2 62 | } 63 | 64 | type Line(piece) = 65 | Dict(Slot, piece) 66 | 67 | pub type Slot { 68 | Slot1 69 | Slot2 70 | Slot3 71 | Slot4 72 | Slot5 73 | Slot6 74 | Slot7 75 | Slot8 76 | Slot9 77 | } 78 | 79 | pub type Card { 80 | Card(rank: Int, suit: Suit) 81 | } 82 | 83 | pub type Suit { 84 | Spade 85 | Heart 86 | Diamond 87 | Club 88 | } 89 | 90 | type Battle = 91 | Dict(Player, Column) 92 | 93 | type Column = 94 | List(Card) 95 | 96 | pub type Formation { 97 | StraightFlush 98 | ThreeOfAKind 99 | Straight 100 | Flush 101 | Pair 102 | HighCard 103 | } 104 | 105 | pub type TotalScore { 106 | TotalScore( 107 | columns: List(#(Score, Score)), 108 | totals: List(#(Option(Player), Int)), 109 | total: #(Option(Player), Int), 110 | ) 111 | } 112 | 113 | pub type Score { 114 | Score( 115 | score: Int, 116 | bonus_formation: Int, 117 | bonus_flank: Int, 118 | formation: Formation, 119 | ) 120 | } 121 | 122 | pub type Errors { 123 | InvalidAction(Message) 124 | NotCurrentPhase 125 | NotCurrentPlayer 126 | EmptyDeck 127 | MaxHandReached 128 | NoCardInHand 129 | NotPlayableSlot 130 | NotClaimableSlot 131 | } 132 | 133 | // --- GameState API --- // 134 | 135 | /// Initializes a new gamestate. 136 | pub fn new() -> GameState { 137 | GameState( 138 | turn: 0, 139 | phase: Draw, 140 | board: new_board(), 141 | sequence: [Player1, Player2], 142 | total_score: new_total_score(), 143 | ) 144 | } 145 | 146 | /// Modifies the game state by applying an action. 147 | pub fn next(state: GameState, action: Message) -> Result(GameState, Errors) { 148 | case state.phase, action { 149 | Draw, DrawCard(player) -> { 150 | Ok(state) 151 | |> result.then(fn(state) { 152 | draw_card(state.board, player) 153 | |> result.map(fn(board) { GameState(..state, board: board) }) 154 | }) 155 | |> result.map(fn(state) { 156 | case are_hands_full(state.board.hands) { 157 | True -> 158 | GameState( 159 | ..state, 160 | turn: state.turn 161 | + 1, 162 | sequence: rotate(state.sequence), 163 | phase: Play, 164 | ) 165 | 166 | False -> state 167 | } 168 | }) 169 | } 170 | 171 | Play, PlayCard(player, slot, card) -> { 172 | Ok(state) 173 | |> result.then(fn(state) { 174 | case first(state.sequence) { 175 | current if player == current -> Ok(state) 176 | _current -> Error(NotCurrentPlayer) 177 | } 178 | }) 179 | |> result.then(fn(state) { 180 | play_card(state.board, player, slot, card) 181 | |> result.map(fn(board) { GameState(..state, board: board) }) 182 | }) 183 | |> result.map(fn(state) { 184 | let total_score = calculate_total_score(state) 185 | GameState(..state, total_score: total_score) 186 | }) 187 | |> result.map(fn(state) { 188 | let hand = get(state.board.hands, player) 189 | case are_moves_spent(hand), deck_size(state) { 190 | True, 0 -> GameState(..state, phase: End) 191 | 192 | True, _ -> GameState(..state, phase: Draw) 193 | 194 | False, _ -> state 195 | } 196 | }) 197 | } 198 | 199 | _phase, action -> { 200 | Error(InvalidAction(action)) 201 | } 202 | } 203 | } 204 | 205 | // --- Introspection API --- // 206 | 207 | ///Retrieves the current deck size from the 208 | pub fn deck_size(state: GameState) -> Int { 209 | list.length(state.board.deck) 210 | } 211 | 212 | /// Retrieves a player's full hand 213 | pub fn player_hand(state: GameState, of player: Player) -> List(Card) { 214 | get(state.board.hands, player) 215 | } 216 | 217 | /// Retrieves the current turn 218 | pub fn current_turn(state: GameState) -> Int { 219 | state.turn 220 | } 221 | 222 | /// Retrieves the current phase 223 | pub fn current_phase(state: GameState) -> Phase { 224 | state.phase 225 | } 226 | 227 | /// Retrieves the current player 228 | pub fn current_player(state: GameState) -> Player { 229 | first(state.sequence) 230 | } 231 | 232 | const slots = [Slot1, Slot2, Slot3, Slot4, Slot5, Slot6, Slot7, Slot8, Slot9] 233 | 234 | /// Retrieves battle columns from a player in the right order. 235 | pub fn columns(state: GameState, of player: Player) -> List(#(Slot, Column)) { 236 | let battleline = state.board.battleline 237 | 238 | slots 239 | |> list.map(fn(slot) { 240 | let battle = get(battleline, slot) 241 | #(slot, battle) 242 | }) 243 | |> list.map(fn(slot_battle) { 244 | let #(slot, battle) = slot_battle 245 | let column = get(battle, player) 246 | #(slot, column) 247 | }) 248 | } 249 | 250 | /// Retrieves a list of slots available for playing a card. 251 | pub fn available_plays(state: GameState, of player: Player) -> List(Slot) { 252 | let battleline = state.board.battleline 253 | 254 | use slot <- list.filter(slots) 255 | let battle = get(battleline, slot) 256 | let column = get(battle, player) 257 | 258 | column 259 | |> available_play() 260 | |> result.is_ok() 261 | } 262 | 263 | /// Retrieves both player's scores per column 264 | pub fn score_columns(state: GameState) -> List(#(Score, Score)) { 265 | state.total_score.columns 266 | } 267 | 268 | /// Retrieves the winning score per column 269 | pub fn score_totals(state: GameState) -> List(#(Option(Player), Int)) { 270 | state.total_score.totals 271 | } 272 | 273 | /// Retrieves the winning score 274 | pub fn score_total(state: GameState) -> #(Option(Player), Int) { 275 | state.total_score.total 276 | } 277 | 278 | // --- Function Helpers --- // 279 | 280 | fn new_board() -> Board { 281 | Board( 282 | deck: new_deck(), 283 | battleline: new_line( 284 | dict.new() 285 | |> dict.insert(Player1, []) 286 | |> dict.insert(Player2, []), 287 | ), 288 | hands: dict.new() 289 | |> dict.insert(Player1, []) 290 | |> dict.insert(Player2, []), 291 | ) 292 | } 293 | 294 | fn new_deck() -> List(Card) { 295 | list.shuffle({ 296 | use rank <- list.flat_map(ranks) 297 | use suit <- list.map(suits) 298 | Card(rank: rank, suit: suit) 299 | }) 300 | } 301 | 302 | fn new_line(of piece: piece) -> Line(piece) { 303 | slots 304 | |> list.map(fn(slot) { #(slot, piece) }) 305 | |> dict.from_list() 306 | } 307 | 308 | fn new_total_score() -> TotalScore { 309 | TotalScore( 310 | columns: list.map(slots, fn(_) { 311 | #(Score(0, 0, 0, HighCard), Score(0, 0, 0, HighCard)) 312 | }), 313 | totals: list.map(slots, fn(_) { figure_player(0) }), 314 | total: #(None, 0), 315 | ) 316 | } 317 | 318 | fn draw_card(board: Board, of player: Player) -> Result(Board, Errors) { 319 | let hand = get(board.hands, player) 320 | use #(card, deck) <- try(draw_card_from_deck(board.deck)) 321 | use new_hand <- try(add_card_to_hand(hand, card)) 322 | let hands = dict.insert(board.hands, player, new_hand) 323 | let board = Board(..board, deck: deck, hands: hands) 324 | Ok(board) 325 | } 326 | 327 | fn draw_card_from_deck(deck: List(Card)) -> Result(#(Card, List(Card)), Errors) { 328 | deck 329 | |> list.pop(fn(_) { True }) 330 | |> result.replace_error(EmptyDeck) 331 | } 332 | 333 | fn add_card_to_hand(hand: List(Card), card: Card) -> Result(List(Card), Errors) { 334 | let new_hand = list.append(hand, [card]) 335 | 336 | case list.length(new_hand) > max_hand_size { 337 | False -> Ok(new_hand) 338 | True -> Error(MaxHandReached) 339 | } 340 | } 341 | 342 | fn card_score(column: Column) -> Int { 343 | column 344 | |> list.map(fn(card) { card.rank }) 345 | |> list.fold(0, fn(sum, rank) { sum + rank }) 346 | } 347 | 348 | fn formation(column: Column) -> Formation { 349 | case list.sort(column, by: rank_compare) { 350 | [card_a, card_b, card_c] -> formation_triplet(card_a, card_b, card_c) 351 | [card_a, card_b] -> formation_pair(card_a, card_b) 352 | _other -> HighCard 353 | } 354 | } 355 | 356 | fn rank_compare(card_a: Card, card_b: Card) -> Order { 357 | let Card(rank: a, ..) = card_a 358 | let Card(rank: b, ..) = card_b 359 | int.compare(a, b) 360 | } 361 | 362 | fn formation_triplet(card_a: Card, card_b: Card, card_c: Card) -> Formation { 363 | let Card(suit: sa, rank: ra) = card_a 364 | let Card(suit: sb, rank: rb) = card_b 365 | let Card(suit: sc, rank: rc) = card_c 366 | 367 | let is_pair = ra == rb || rb == rc || rc == ra 368 | let is_triplet = ra == rb && rb == rc 369 | let is_flush = sa == sb && sb == sc 370 | let is_straight = { ra + 1 == rb } && { rb + 1 == rc } 371 | 372 | case is_pair, is_triplet, is_flush, is_straight { 373 | _bool, False, True, True -> StraightFlush 374 | _bool, True, False, False -> ThreeOfAKind 375 | _bool, False, False, True -> Straight 376 | _bool, False, True, False -> Flush 377 | True, False, False, False -> Pair 378 | _bool, _bool, _bool, _bool -> HighCard 379 | } 380 | } 381 | 382 | fn formation_pair(card_a: Card, card_b: Card) -> Formation { 383 | case card_a.rank, card_b.rank { 384 | ra, rb if ra == rb -> Pair 385 | _, _ -> HighCard 386 | } 387 | } 388 | 389 | fn play_card( 390 | board: Board, 391 | of player: Player, 392 | at slot: Slot, 393 | with card: Card, 394 | ) -> Result(Board, Errors) { 395 | let hand = get(board.hands, player) 396 | let battle = get(board.battleline, slot) 397 | let column = get(battle, player) 398 | 399 | use #(card, hand) <- try(pick_card(from: hand, where: card)) 400 | use column <- try(available_play(is: column)) 401 | 402 | let hands = dict.insert(board.hands, player, hand) 403 | 404 | let column = list.append(column, [card]) 405 | let battle = dict.insert(battle, player, column) 406 | let battleline = dict.insert(board.battleline, slot, battle) 407 | 408 | let board = Board(..board, hands: hands, battleline: battleline) 409 | 410 | Ok(board) 411 | } 412 | 413 | fn pick_card( 414 | from hand: List(Card), 415 | where card: Card, 416 | ) -> Result(#(Card, List(Card)), Errors) { 417 | hand 418 | |> list.pop(fn(c) { c == card }) 419 | |> result.replace_error(NoCardInHand) 420 | } 421 | 422 | fn available_play(is column: Column) -> Result(Column, Errors) { 423 | case column { 424 | [_, _, _] -> Error(NotPlayableSlot) 425 | column -> Ok(column) 426 | } 427 | } 428 | 429 | fn rotate(list: List(x)) -> List(x) { 430 | let assert [head, ..tail] = list 431 | list.append(tail, [head]) 432 | } 433 | 434 | fn first(list: List(x)) -> x { 435 | let assert [head, ..] = list 436 | head 437 | } 438 | 439 | fn are_hands_full(hands: Dict(Player, List(Card))) -> Bool { 440 | let p1_hand = get(hands, Player1) 441 | let p2_hand = get(hands, Player2) 442 | 443 | list.length(p1_hand) >= max_hand_size && list.length(p2_hand) >= max_hand_size 444 | } 445 | 446 | fn are_moves_spent(hand: List(Card)) -> Bool { 447 | let moves = max_hand_size - list.length(hand) 448 | 449 | plays_per_turn == moves 450 | } 451 | 452 | fn calculate_total_score(state: GameState) -> TotalScore { 453 | let columns = calculate_columns(state) 454 | let totals = calculate_totals(columns) 455 | let total = calculate_total(totals) 456 | 457 | TotalScore( 458 | columns: columns, 459 | totals: list.map(totals, figure_player), 460 | total: figure_player(total), 461 | ) 462 | } 463 | 464 | fn calculate_columns(state: GameState) -> List(#(Score, Score)) { 465 | let columns = 466 | list.index_map(slots, fn(slot, index) { 467 | let assert Ok(#(s1, s2)) = list.at(state.total_score.columns, index) 468 | 469 | let battle = get(state.board.battleline, slot) 470 | let column_p1 = get(battle, Player1) 471 | let column_p2 = get(battle, Player2) 472 | 473 | #(score(column_p1, s1.bonus_flank), score(column_p2, s2.bonus_flank)) 474 | }) 475 | 476 | let flanks = flank_bonuses(columns) 477 | 478 | let columns = 479 | list.map(slots, fn(slot) { 480 | let battle = get(state.board.battleline, slot) 481 | let column_p1 = get(battle, Player1) 482 | let column_p2 = get(battle, Player2) 483 | 484 | #(score(column_p1, 0), score(column_p2, 0)) 485 | }) 486 | 487 | list.zip(columns, flanks) 488 | |> list.map(fn(scores) { 489 | let #(#(score_p1, score_p2), bonus) = scores 490 | 491 | case bonus { 492 | Some(Player1) -> #(Score(..score_p1, bonus_flank: flank_bonus), score_p2) 493 | Some(Player2) -> #(score_p1, Score(..score_p2, bonus_flank: flank_bonus)) 494 | None -> #(score_p1, score_p2) 495 | } 496 | }) 497 | } 498 | 499 | fn score(column: List(Card), flank: Int) -> Score { 500 | let formation = formation(column) 501 | 502 | Score( 503 | score: card_score(column), 504 | bonus_formation: formation_bonus(formation), 505 | bonus_flank: flank, 506 | formation: formation, 507 | ) 508 | } 509 | 510 | fn formation_bonus(formation: Formation) -> Int { 511 | case formation { 512 | StraightFlush -> straight_flush 513 | ThreeOfAKind -> three_of_a_kind 514 | Straight -> straight 515 | Flush -> flush 516 | Pair -> pair 517 | HighCard -> highcard 518 | } 519 | } 520 | 521 | fn flank_bonuses(scores: List(#(Score, Score))) -> List(Option(Player)) { 522 | scores 523 | |> list.map(fn(score) { 524 | let #(score_p1, score_p2) = score 525 | let score_p1 = 526 | score_p1.score + score_p1.bonus_formation + score_p1.bonus_flank 527 | let score_p2 = 528 | score_p2.score + score_p2.bonus_formation + score_p2.bonus_flank 529 | 530 | score_p1 - score_p2 531 | }) 532 | |> list.window(3) 533 | |> list.map(fn(claims) { 534 | case claims { 535 | [s1, s2, s3] if s1 > 0 && s2 > 0 && s3 > 0 -> Some(Player1) 536 | [s1, s2, s3] if s1 < 0 && s2 < 0 && s3 < 0 -> Some(Player2) 537 | _other -> None 538 | } 539 | }) 540 | |> list.prepend(None) 541 | |> list.append([None]) 542 | } 543 | 544 | fn calculate_totals(scores: List(#(Score, Score))) -> List(Int) { 545 | list.map(scores, fn(score) { 546 | let #(score_p1, score_p2) = score 547 | 548 | let score_p1 = 549 | score_p1.score + score_p1.bonus_formation + score_p1.bonus_flank 550 | let score_p2 = 551 | score_p2.score + score_p2.bonus_formation + score_p2.bonus_flank 552 | score_p1 - score_p2 553 | }) 554 | } 555 | 556 | fn calculate_total(scores: List(Int)) -> Int { 557 | list.fold(scores, 0, fn(total, score) { total + score }) 558 | } 559 | 560 | fn figure_player(score: Int) -> #(Option(Player), Int) { 561 | case score { 562 | score if score > 0 -> #(Some(Player1), score) 563 | score if score < 0 -> #(Some(Player2), int.absolute_value(score)) 564 | _score -> #(None, 0) 565 | } 566 | } 567 | 568 | fn get(map: Dict(key, value), key: key) -> value { 569 | let assert Ok(value) = dict.get(map, key) 570 | value 571 | } 572 | -------------------------------------------------------------------------------- /src/luster/web/line_poker/view.gleam: -------------------------------------------------------------------------------- 1 | import gleam/bit_array 2 | import gleam/int 3 | import gleam/list 4 | import gleam/option.{type Option, None, Some} 5 | import gleam/pair 6 | import gleam/string 7 | import luster/line_poker/game as g 8 | import nakai/html 9 | import nakai/html/attrs 10 | 11 | // --- Elm-ish architecture with a Model and Init, Update, View callbacks --- // 12 | 13 | pub type Message { 14 | SelectCard(g.Card) 15 | ToggleScoring 16 | Alert(g.Errors) 17 | UpdateGame(g.GameState) 18 | } 19 | 20 | pub type Model { 21 | Model( 22 | alert: Option(g.Errors), 23 | selected_card: Option(g.Card), 24 | toggle_scoring: Bool, 25 | gamestate: g.GameState, 26 | ) 27 | } 28 | 29 | pub fn init(gamestate: g.GameState) -> Model { 30 | Model( 31 | alert: None, 32 | selected_card: None, 33 | toggle_scoring: True, 34 | gamestate: gamestate, 35 | ) 36 | } 37 | 38 | pub fn update(model: Model, message: Message) -> Model { 39 | case message { 40 | SelectCard(card) -> { 41 | Model(..model, alert: None, selected_card: Some(card)) 42 | } 43 | 44 | ToggleScoring -> { 45 | Model(..model, alert: None, toggle_scoring: !model.toggle_scoring) 46 | } 47 | 48 | Alert(error) -> { 49 | Model(..model, alert: Some(error)) 50 | } 51 | 52 | UpdateGame(gamestate) -> { 53 | Model(..model, gamestate: gamestate) 54 | } 55 | } 56 | } 57 | 58 | pub fn view(model: Model) -> html.Node(a) { 59 | html.Fragment([ 60 | game_info(model.gamestate, model.selected_card, model.alert), 61 | board(model.gamestate, model.selected_card), 62 | score_popup(model.gamestate, model.toggle_scoring), 63 | ]) 64 | } 65 | 66 | // --- Helpers to build the view --- // 67 | 68 | fn game_info( 69 | state: g.GameState, 70 | selected_card: Option(g.Card), 71 | alert: Option(g.Errors), 72 | ) -> html.Node(a) { 73 | let #(_color, message) = case alert { 74 | Some(g.InvalidAction(_action)) -> #("red", "Invalid Action.") 75 | Some(g.NotCurrentPhase) -> #("yellow", "Not current phase.") 76 | Some(g.NotCurrentPlayer) -> #("yellow", "Not current player.") 77 | Some(g.NoCardInHand) -> #("yellow", "Card not in hand.") 78 | Some(g.EmptyDeck) -> #("green", "Deck already empty.") 79 | Some(g.MaxHandReached) -> #("green", "Hand at max.") 80 | Some(g.NotClaimableSlot) -> #("green", "Slot is not claimable.") 81 | Some(g.NotPlayableSlot) -> #("green", "Slot is not playable.") 82 | None -> #("purple", "") 83 | } 84 | 85 | let action = case 86 | g.current_turn(state), 87 | g.current_phase(state), 88 | selected_card 89 | { 90 | 0, g.Draw, _selected -> "Initial Draw." 91 | _, g.Draw, _selected -> "Draw a card from pile." 92 | _, g.Play, Some(_) -> "Play a card from hand." 93 | _, g.Play, None -> "Select a card from hand." 94 | _, g.End, _ -> "" 95 | } 96 | 97 | let player = case 98 | g.current_turn(state), 99 | g.current_phase(state), 100 | g.current_player(state) 101 | { 102 | 0, _phase, _player -> "" 103 | _, g.End, _player -> "" 104 | _, _phase, g.Player1 -> "Player 1," 105 | _, _phase, g.Player2 -> "Player 2," 106 | } 107 | 108 | html.section([attrs.class("player-info column center")], [ 109 | html.div([attrs.class("player row center ")], [ 110 | html.span_text([], message <> " " <> player <> " " <> action), 111 | ]), 112 | ]) 113 | } 114 | 115 | fn board(state: g.GameState, selected_card: Option(g.Card)) -> html.Node(a) { 116 | html.section([attrs.class("play row evenly")], [ 117 | html.div([attrs.class("column")], [ 118 | html.div([attrs.class("s2")], []), 119 | html.div([attrs.class("s2 column center")], [deck(state, g.Player2)]), 120 | html.div([attrs.class("s6 column center")], [hand(state, g.Player2)]), 121 | html.div([attrs.class("s2")], []), 122 | ]), 123 | html.div([attrs.class("column")], [ 124 | html.div([attrs.class("s2")], []), 125 | html.div([attrs.class("board s8 row")], [ 126 | html.div([attrs.class("column center")], [ 127 | score_columns(state, g.Player2), 128 | ]), 129 | html.div([attrs.class("column center")], [ 130 | view_slots(state, g.Player2, selected_card), 131 | ]), 132 | html.div([attrs.class("column center")], [score_totals(state)]), 133 | html.div([attrs.class("column center")], [ 134 | view_slots(state, g.Player1, selected_card), 135 | ]), 136 | html.div([attrs.class("column center")], [ 137 | score_columns(state, g.Player1), 138 | ]), 139 | ]), 140 | html.div([attrs.class("s2")], []), 141 | ]), 142 | html.div([attrs.class("column")], [ 143 | html.div([attrs.class("s2")], []), 144 | html.div([attrs.class("s6 column center")], [hand(state, g.Player1)]), 145 | html.div([attrs.class("s2 column center")], [deck(state, g.Player1)]), 146 | html.div([attrs.class("s2")], []), 147 | ]), 148 | ]) 149 | } 150 | 151 | fn score_popup(state: g.GameState, toggle_scoring: Bool) -> html.Node(a) { 152 | case g.current_phase(state) { 153 | g.End -> { 154 | let dataset = dataset([#("event", encode_popup_toggle())]) 155 | 156 | html.section([attrs.class("popup column center"), ..dataset], [ 157 | case toggle_scoring { 158 | True -> html.div([attrs.class("row center")], [scores(state)]) 159 | False -> html.Nothing 160 | }, 161 | ]) 162 | } 163 | 164 | _other -> { 165 | html.Nothing 166 | } 167 | } 168 | } 169 | 170 | fn hand(state: g.GameState, player: g.Player) -> html.Node(a) { 171 | let hand = g.player_hand(state, of: player) 172 | 173 | html.div( 174 | [attrs.class("hand column")], 175 | list.map(hand, fn(card) { 176 | click([#("event", encode_select_card(player, card))], card_front(card)) 177 | }), 178 | ) 179 | } 180 | 181 | fn deck(state: g.GameState, player: g.Player) -> html.Node(a) { 182 | let size = g.deck_size(state) 183 | 184 | click([#("event", encode_draw_card(player))], pile(size)) 185 | } 186 | 187 | fn score_totals(state: g.GameState) -> html.Node(a) { 188 | let totals = g.score_totals(state) 189 | 190 | html.div([attrs.class("totals scores")], { 191 | use score <- list.map(totals) 192 | case score { 193 | #(Some(player), total) -> { 194 | let score = int.to_string(total) 195 | 196 | html.div([attrs.class("score" <> " " <> player_class(player))], [ 197 | html.span_text([], score), 198 | ]) 199 | } 200 | 201 | #(None, total) -> { 202 | let score = int.to_string(total) 203 | 204 | html.div([attrs.class("score")], [html.span_text([], score)]) 205 | } 206 | } 207 | }) 208 | } 209 | 210 | fn score_columns(state: g.GameState, player: g.Player) -> html.Node(a) { 211 | let columns = g.score_columns(state) 212 | 213 | let scores = case player { 214 | g.Player1 -> list.map(columns, pair.first) 215 | g.Player2 -> list.map(columns, pair.second) 216 | } 217 | 218 | html.div([attrs.class("scores")], { 219 | use score <- list.map(scores) 220 | let card = int.to_string(score.score) 221 | let formation = int.to_string(score.bonus_formation) 222 | let flank = int.to_string(score.bonus_flank) 223 | 224 | let card = [html.span_text([attrs.class("unit")], card)] 225 | 226 | let formation = case score.bonus_formation { 227 | 0 -> [] 228 | 229 | _ -> [ 230 | html.span_text([attrs.class("formation")], " + "), 231 | html.span_text([attrs.class("formation")], formation), 232 | ] 233 | } 234 | 235 | let flank = case score.bonus_flank { 236 | 0 -> [] 237 | 238 | _ -> [ 239 | html.span_text([attrs.class("flank")], " + "), 240 | html.span_text([attrs.class("flank")], flank), 241 | ] 242 | } 243 | 244 | html.div( 245 | [attrs.class("score" <> " " <> player_class(player))], 246 | list.concat([card, formation, flank]), 247 | ) 248 | }) 249 | } 250 | 251 | fn view_slots( 252 | state: g.GameState, 253 | player: g.Player, 254 | selected_card: Option(g.Card), 255 | ) -> html.Node(a) { 256 | let columns = g.columns(state, player) 257 | let class = attrs.class("slot" <> " " <> player_class(player)) 258 | 259 | html.div([attrs.class("slots")], { 260 | use slot_column <- list.map(columns) 261 | let #(slot, column) = slot_column 262 | 263 | case selected_card { 264 | Some(card) -> 265 | click( 266 | [#("event", encode_play_card(player, slot, card))], 267 | html.div([class], list.map(column, fn(card) { card_front(card) })), 268 | ) 269 | 270 | None -> html.div([class], list.map(column, fn(card) { card_front(card) })) 271 | } 272 | }) 273 | } 274 | 275 | fn scores(state: g.GameState) -> html.Node(a) { 276 | let scores = g.score_columns(state) 277 | let total = g.score_total(state) 278 | 279 | let score_group = 280 | ScoreGroup(#(0, 0), #(0, 0), #(0, 0), #(0, 0), #(0, 0), #(0, 0), #(0, 0)) 281 | 282 | let group = fn(group: ScoreGroup, score: g.Score) { 283 | case score.formation { 284 | g.StraightFlush -> { 285 | let #(count, bonus) = group.straight_flush 286 | let stats = #(count + 1, bonus + score.bonus_formation) 287 | ScoreGroup(..group, straight_flush: stats) 288 | } 289 | g.ThreeOfAKind -> { 290 | let #(count, bonus) = group.three_of_a_kind 291 | let stats = #(count + 1, bonus + score.bonus_formation) 292 | ScoreGroup(..group, three_of_a_kind: stats) 293 | } 294 | g.Straight -> { 295 | let #(count, bonus) = group.straight 296 | let stats = #(count + 1, bonus + score.bonus_formation) 297 | ScoreGroup(..group, straight: stats) 298 | } 299 | g.Flush -> { 300 | let #(count, bonus) = group.flush 301 | let stats = #(count + 1, bonus + score.bonus_formation) 302 | ScoreGroup(..group, flush: stats) 303 | } 304 | g.Pair -> { 305 | let #(count, bonus) = group.pair 306 | let stats = #(count + 1, bonus + score.bonus_formation) 307 | ScoreGroup(..group, pair: stats) 308 | } 309 | g.HighCard -> { 310 | let #(count, bonus) = group.highcard 311 | let stats = #(count + 1, bonus + score.bonus_formation) 312 | ScoreGroup(..group, highcard: stats) 313 | } 314 | } 315 | } 316 | 317 | let sum_score = fn(sum: Int, score: g.Score) { sum + score.score } 318 | let sum_form = fn(sum: Int, score: g.Score) { sum + score.bonus_formation } 319 | let sum_flank = fn(sum: Int, score: g.Score) { sum + score.bonus_flank } 320 | 321 | let p1_scores = list.map(scores, pair.first) 322 | let p1_score_group = list.fold(p1_scores, score_group, group) 323 | let p1_card_total = list.fold(p1_scores, 0, sum_score) 324 | let p1_form_total = list.fold(p1_scores, 0, sum_form) 325 | let p1_flank_total = list.fold(p1_scores, 0, sum_flank) 326 | 327 | let p2_scores = list.map(scores, pair.second) 328 | let p2_score_group = list.fold(p2_scores, score_group, group) 329 | let p2_card_total = list.fold(p2_scores, 0, sum_score) 330 | let p2_form_total = list.fold(p2_scores, 0, sum_form) 331 | let p2_flank_total = list.fold(p2_scores, 0, sum_flank) 332 | 333 | html.div([attrs.class("final-score sparkle")], [ 334 | html.Fragment([ 335 | html.div([attrs.class("score-winner")], [html.h1_text([], "Game!")]), 336 | html.div([attrs.class("score-group")], [ 337 | html.div([], [ 338 | group_table(p1_score_group), 339 | html.Element("hr", [], []), 340 | subtotals_table(p1_card_total, p1_form_total, p1_flank_total), 341 | html.Element("hr", [], []), 342 | totals_table(p1_card_total + p1_form_total + p1_flank_total), 343 | ]), 344 | html.div([], [ 345 | group_table(p2_score_group), 346 | html.Element("hr", [], []), 347 | subtotals_table(p2_card_total, p2_form_total, p2_flank_total), 348 | html.Element("hr", [], []), 349 | totals_table(p2_card_total + p2_form_total + p2_flank_total), 350 | ]), 351 | ]), 352 | winner(total), 353 | ]), 354 | ]) 355 | } 356 | 357 | fn card_front(card: g.Card) -> html.Node(a) { 358 | let utf = suit_utf(card.suit) 359 | let rank = rank_utf(card.rank) 360 | let color = suit_color(card.suit) 361 | 362 | html.div([attrs.class("card front " <> color)], [ 363 | html.div([attrs.class("corner upper-left")], [ 364 | html.p_text([], rank), 365 | html.p_text([], utf), 366 | ]), 367 | html.div([attrs.class("graphic")], [html.p_text([], utf)]), 368 | html.div([attrs.class("corner bottom-right")], [ 369 | html.p_text([], rank), 370 | html.p_text([], utf), 371 | ]), 372 | ]) 373 | } 374 | 375 | fn pile(size: Int) -> html.Node(a) { 376 | let count = case size { 377 | x if x > 48 -> 13 378 | x if x > 44 -> 12 379 | x if x > 40 -> 11 380 | x if x > 36 -> 10 381 | x if x > 32 -> 09 382 | x if x > 28 -> 08 383 | x if x > 24 -> 07 384 | x if x > 20 -> 06 385 | x if x > 16 -> 05 386 | x if x > 12 -> 04 387 | x if x > 08 -> 03 388 | x if x > 04 -> 02 389 | x if x > 00 -> 01 390 | _ -> 0 391 | } 392 | 393 | let card = card_back() 394 | html.div([attrs.class("draw-pile")], list.repeat(card, count)) 395 | } 396 | 397 | fn card_back() -> html.Node(a) { 398 | html.div([attrs.class("card back sparkle")], []) 399 | } 400 | 401 | fn suit_utf(suit: g.Suit) -> String { 402 | case suit { 403 | g.Spade -> "♠" 404 | g.Heart -> "♥" 405 | g.Diamond -> "♦" 406 | g.Club -> "♣" 407 | } 408 | } 409 | 410 | fn rank_utf(rank: Int) -> String { 411 | int.to_string(rank) 412 | } 413 | 414 | fn suit_color(suit: g.Suit) -> String { 415 | case suit { 416 | g.Spade -> "blue" 417 | g.Heart -> "red" 418 | g.Diamond -> "green" 419 | g.Club -> "purple" 420 | } 421 | } 422 | 423 | fn player_class(player: g.Player) -> String { 424 | case player { 425 | g.Player1 -> "player-1" 426 | g.Player2 -> "player-2" 427 | } 428 | } 429 | 430 | type ScoreGroup { 431 | ScoreGroup( 432 | straight_flush: #(Int, Int), 433 | three_of_a_kind: #(Int, Int), 434 | straight: #(Int, Int), 435 | flush: #(Int, Int), 436 | pair: #(Int, Int), 437 | flank_bonus: #(Int, Int), 438 | highcard: #(Int, Int), 439 | ) 440 | } 441 | 442 | fn group_table(group: ScoreGroup) -> html.Node(a) { 443 | let s = fn(x) { int.to_string(x) } 444 | 445 | let suit = fn(suit: g.Suit, rank: Int) -> html.Node(a) { 446 | let utf = suit_utf(suit) 447 | let color = suit_color(suit) 448 | let rank = int.to_string(rank) 449 | 450 | html.span_text([attrs.class(color)], rank <> utf <> " ") 451 | } 452 | 453 | let data = [ 454 | #("Straight Flush", group.straight_flush.0, [ 455 | suit(g.Spade, 3), 456 | suit(g.Spade, 2), 457 | suit(g.Spade, 1), 458 | ]), 459 | #("Three of Kind", group.three_of_a_kind.0, [ 460 | suit(g.Spade, 7), 461 | suit(g.Diamond, 7), 462 | suit(g.Club, 7), 463 | ]), 464 | #("Straight", group.straight.0, [ 465 | suit(g.Heart, 6), 466 | suit(g.Club, 5), 467 | suit(g.Spade, 4), 468 | ]), 469 | #("Flush", group.flush.0, [ 470 | suit(g.Heart, 8), 471 | suit(g.Heart, 4), 472 | suit(g.Heart, 2), 473 | ]), 474 | #("Pair", group.pair.0, [suit(g.Club, 4), suit(g.Diamond, 4)]), 475 | ] 476 | 477 | html.table([], [ 478 | html.tbody([], { 479 | use #(name, count, suit) <- list.map(data) 480 | html.tr([], [ 481 | html.td([], [html.div_text([], name), html.div([], suit)]), 482 | html.td_text([], "x" <> s(count)), 483 | ]) 484 | }), 485 | ]) 486 | } 487 | 488 | fn subtotals_table(card: Int, formation: Int, flank: Int) -> html.Node(a) { 489 | html.table([], [ 490 | html.tbody([], [ 491 | html.tr([], [ 492 | html.td_text([], "Card points"), 493 | html.td_text([], int.to_string(card)), 494 | ]), 495 | html.tr([], [ 496 | html.td_text([], "Formation points"), 497 | html.td_text([], int.to_string(formation)), 498 | ]), 499 | html.tr([], [ 500 | html.td_text([], "Flank points"), 501 | html.td_text([], int.to_string(flank)), 502 | ]), 503 | ]), 504 | ]) 505 | } 506 | 507 | fn totals_table(total: Int) -> html.Node(a) { 508 | html.table([], [ 509 | html.tbody([], [ 510 | html.tr([], [ 511 | html.td_text([], "Total"), 512 | html.td_text([], int.to_string(total)), 513 | ]), 514 | ]), 515 | ]) 516 | } 517 | 518 | fn winner(total: #(Option(g.Player), Int)) -> html.Node(a) { 519 | let total = case total { 520 | #(Some(g.Player1), total) -> #("Player 1!", int.to_string(total)) 521 | #(Some(g.Player2), total) -> #("Player 2!", int.to_string(total)) 522 | #(None, total) -> #("DRAW", int.to_string(total)) 523 | } 524 | 525 | html.div([attrs.class("score-winner")], [ 526 | html.h2_text([], total.0), 527 | html.h3_text([], total.1 <> " points"), 528 | ]) 529 | } 530 | 531 | // --- This code encodes "clicks" into the HTML --- // 532 | 533 | fn click( 534 | params: List(#(String, BitArray)), 535 | markup: html.Node(a), 536 | ) -> html.Node(a) { 537 | let dataset = dataset(params) 538 | html.div(dataset, [markup]) 539 | } 540 | 541 | fn dataset(params: List(#(String, BitArray))) -> List(attrs.Attr(a)) { 542 | let to_string = fn(bits) { 543 | let assert Ok(string) = bit_array.to_string(bits) 544 | string 545 | } 546 | 547 | params 548 | |> list.map(fn(param) { #(param.0, to_string(param.1)) }) 549 | |> list.map(data_attr) 550 | } 551 | 552 | fn data_attr(param: #(String, String)) -> attrs.Attr(a) { 553 | attrs.Attr(name: "data-" <> param.0, value: param.1) 554 | } 555 | 556 | fn encode_draw_card(player: g.Player) -> BitArray { 557 | let player = encode_player(player) 558 | <<"draw-card":utf8, player:bits>> 559 | } 560 | 561 | fn encode_play_card(player: g.Player, slot: g.Slot, card: g.Card) -> BitArray { 562 | let player = encode_player(player) 563 | let slot = encode_slot(slot) 564 | let rank = encode_rank(card.rank) 565 | let suit = encode_suit(card.suit) 566 | <<"play-card":utf8, player:bits, slot:bits, suit:bits, rank:bits>> 567 | } 568 | 569 | fn encode_select_card(player: g.Player, card: g.Card) -> BitArray { 570 | let _player = encode_player(player) 571 | let suit = encode_suit(card.suit) 572 | let rank = encode_rank(card.rank) 573 | //<<"select-card":utf8, player:bits, suit:bits, rank:bits>> 574 | <<"select-card":utf8, suit:bits, rank:bits>> 575 | } 576 | 577 | fn encode_popup_toggle() -> BitArray { 578 | <<"popup-toggle":utf8>> 579 | } 580 | 581 | fn encode_player(player: g.Player) -> BitArray { 582 | case player { 583 | g.Player1 -> <<"p1":utf8>> 584 | g.Player2 -> <<"p2":utf8>> 585 | } 586 | } 587 | 588 | fn encode_slot(slot: g.Slot) -> BitArray { 589 | case slot { 590 | g.Slot1 -> <<"1":utf8>> 591 | g.Slot2 -> <<"2":utf8>> 592 | g.Slot3 -> <<"3":utf8>> 593 | g.Slot4 -> <<"4":utf8>> 594 | g.Slot5 -> <<"5":utf8>> 595 | g.Slot6 -> <<"6":utf8>> 596 | g.Slot7 -> <<"7":utf8>> 597 | g.Slot8 -> <<"8":utf8>> 598 | g.Slot9 -> <<"9":utf8>> 599 | } 600 | } 601 | 602 | fn encode_rank(rank: Int) -> BitArray { 603 | let string = 604 | rank 605 | |> int.to_string() 606 | |> string.pad_left(2, "0") 607 | 608 | <> 609 | } 610 | 611 | fn encode_suit(suit: g.Suit) -> BitArray { 612 | case suit { 613 | g.Spade -> <<"♠":utf8>> 614 | g.Heart -> <<"♥":utf8>> 615 | g.Diamond -> <<"♦":utf8>> 616 | g.Club -> <<"♣":utf8>> 617 | } 618 | } 619 | --------------------------------------------------------------------------------