From fff5868a3197fc8817db3f0e682a0afd9066def8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl?= Date: Fri, 26 Jan 2024 03:19:44 -0800 Subject: [PATCH] bye bye forms! --- gleam.toml | 2 + manifest.toml | 11 +- src/luster.gleam | 16 +- src/luster/events.gleam | 158 ++++++++---- src/luster/game/cardfield.gleam | 63 +++-- src/luster/session.gleam | 10 + src/luster/store.gleam | 95 +++++--- src/luster/web.gleam | 82 ++++--- src/luster/web/codec.gleam | 126 ++++++++++ src/luster/web/pages/game.gleam | 386 +++++++----------------------- src/luster/web/pages/home.gleam | 13 +- src/luster/web/pages/layout.gleam | 29 --- 12 files changed, 490 insertions(+), 501 deletions(-) create mode 100644 src/luster/session.gleam create mode 100644 src/luster/web/codec.gleam delete mode 100644 src/luster/web/pages/layout.gleam diff --git a/gleam.toml b/gleam.toml index c21b8a5..ca9ec38 100644 --- a/gleam.toml +++ b/gleam.toml @@ -15,8 +15,10 @@ gleam_stdlib = "~> 0.34" gleam_erlang = "~> 0.24" gleam_otp = "~> 0.9" gleam_http = "~> 3.5" +gleam_json = "~> 1.0" mist = "~> 0.15" wisp = "~> 0.10.0" +chip = "~> 0.1.8" nakai = "~> 0.9.0" envoy = "~> 1.0" diff --git a/manifest.toml b/manifest.toml index e4713f2..3e32af3 100644 --- a/manifest.toml +++ b/manifest.toml @@ -2,28 +2,31 @@ # You typically do not need to edit this file packages = [ + { name = "chip", version = "0.1.8", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "chip", source = "hex", outer_checksum = "F7968E064117EC08C40EA7BAC4F36FFCD72E3FC3340558EFC1E843AC628AD2F2" }, { name = "envoy", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "F9D3AFCF8627E8CBD0FA7296D7187BD024B8DBCF56A152E111A8ECEE27E5E45D" }, { name = "exception", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "984401CFC95BCA87C391E36194D2B9E5B946467D44893FADB1CA4ACD4B7A29CE" }, { name = "gleam_crypto", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "DE1FC4E631CA374AB29CCAEAC043EE171B86114D7DC66DD483F0A93BF0C4C6FF" }, { name = "gleam_erlang", version = "0.24.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "26BDB52E61889F56A291CB34167315780EE4AA20961917314446542C90D1C1A0" }, { name = "gleam_http", version = "3.5.3", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "C2FC3322203B16F897C1818D9810F5DEFCE347F0751F3B44421E1261277A7373" }, - { name = "gleam_json", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "8B197DD5D578EA6AC2C0D4BDC634C71A5BCA8E7DB5F47091C263ECB411A60DF3" }, + { name = "gleam_json", version = "1.0.0", build_tools = ["gleam"], requirements = ["thoas", "gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "8B197DD5D578EA6AC2C0D4BDC634C71A5BCA8E7DB5F47091C263ECB411A60DF3" }, { name = "gleam_otp", version = "0.9.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "5FADBBEC5ECF3F8B6BE91101D432758503192AE2ADBAD5602158977341489F71" }, { name = "gleam_stdlib", version = "0.34.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1FB8454D2991E9B4C0C804544D8A9AD0F6184725E20D63C3155F0AEB4230B016" }, { name = "gleeunit", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "D364C87AFEB26BDB4FB8A5ABDE67D635DC9FA52D6AB68416044C35B096C6882D" }, - { name = "glisten", version = "0.9.2", build_tools = ["gleam"], requirements = ["gleam_otp", "gleam_stdlib", "gleam_erlang"], otp_app = "glisten", source = "hex", outer_checksum = "C960B6CF25D4AABAB01211146E9B57E11827B9C49E4175217E0FB7EF5BCB0FF7" }, + { name = "glisten", version = "0.10.2", build_tools = ["gleam"], requirements = ["gleam_otp", "gleam_stdlib", "gleam_erlang"], otp_app = "glisten", source = "hex", outer_checksum = "461AE0EC3C2BDCC8B581A0CE07D49597A61226B410A3FE7E237EB924D0D18536" }, { name = "marceau", version = "1.1.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "1AAD727A30BE0F95562C3403BB9B27C823797AD90037714255EEBF617B1CDA81" }, - { name = "mist", version = "0.15.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_http", "gleam_otp", "gleam_erlang", "glisten"], otp_app = "mist", source = "hex", outer_checksum = "49F51DDB64D7B2832F72727CC9721C478D6B524C96EA444C601A19D01E023C03" }, + { name = "mist", version = "0.17.0", build_tools = ["gleam"], requirements = ["gleam_otp", "gleam_http", "glisten", "gleam_erlang", "gleam_stdlib"], otp_app = "mist", source = "hex", outer_checksum = "DA8ACEE52C1E4892A75181B3166A4876D8CBC69D555E4770250BC84C80F75524" }, { name = "nakai", version = "0.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "nakai", source = "hex", outer_checksum = "F6FFED9EF4B0E14C7A09B2FB87B42D3A93EFE024FD0299C11F041E92321163A6" }, { name = "simplifile", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "359CD7006E2F69255025C858CCC6407C11A876EC179E6ED1E46809E8DC6B1AAD" }, { name = "thoas", version = "0.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "4918D50026C073C4AB1388437132C77A6F6F7C8AC43C60C13758CC0ADCE2134E" }, - { name = "wisp", version = "0.10.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "mist", "gleam_http", "marceau", "simplifile", "exception", "gleam_crypto", "gleam_stdlib", "gleam_json"], otp_app = "wisp", source = "hex", outer_checksum = "744FF91702078301BDF8FE76F26C14B658A7D151D867FA6995762318ED2536A0" }, + { name = "wisp", version = "0.10.0", build_tools = ["gleam"], requirements = ["gleam_http", "exception", "gleam_crypto", "simplifile", "gleam_erlang", "marceau", "gleam_json", "gleam_stdlib", "mist"], otp_app = "wisp", source = "hex", outer_checksum = "744FF91702078301BDF8FE76F26C14B658A7D151D867FA6995762318ED2536A0" }, ] [requirements] +chip = { version = "~> 0.1.8" } envoy = { version = "~> 1.0" } gleam_erlang = { version = "~> 0.24" } gleam_http = { version = "~> 3.5" } +gleam_json = { version = "~> 1.0" } gleam_otp = { version = "~> 0.9" } gleam_stdlib = { version = "~> 0.34" } gleeunit = { version = "~> 1.0" } diff --git a/src/luster.gleam b/src/luster.gleam index ed4f544..c32d545 100644 --- a/src/luster.gleam +++ b/src/luster.gleam @@ -6,7 +6,8 @@ import luster/store import luster/web import luster/web/pages/game import mist -import gleam/io +import chip +import luster/session // TODO: Create a web, games and luster/runtime contexts // TODO: Add a proper supervision tree @@ -17,20 +18,17 @@ import gleam/io // }) pub fn main() -> Nil { - // Grab this secret from somewhere let assert Ok(store) = store.start() - - let selector: process.Selector(x) = process.new_selector() + let assert Ok(registry) = chip.start() + let session = session.Session(store: store, registry: registry) let request_pipeline = fn(request: request.Request(mist.Connection)) -> response.Response( mist.ResponseData, ) { - let context = web.Context(store: store, params: [], selector: selector) - - web.router(request, context) + web.router(request, session) } - let assert Ok(Nil) = + let assert Ok(_server) = mist.new(request_pipeline) |> mist.port(4444) |> mist.start_https( @@ -45,6 +43,6 @@ pub fn main() -> Nil { fn env(key: String) -> String { case envoy.get(key) { Ok(value) -> value - Error(Nil) -> panic as "unable to find " <> key + Error(Nil) -> panic as "unable to find ENV" } } diff --git a/src/luster/events.gleam b/src/luster/events.gleam index fc6a1a1..3743bc6 100644 --- a/src/luster/events.gleam +++ b/src/luster/events.gleam @@ -1,85 +1,103 @@ -import gleam/erlang/process -import gleam/otp/actor -import gleam/io +import chip import gleam/bit_array -import gleam/option.{type Option, Some} +import gleam/erlang/process +import gleam/function import gleam/http/request.{type Request} import gleam/http/response.{type Response} +import gleam/io +import gleam/json +import gleam/option.{type Option, Some} +import gleam/otp/actor +import gleam/result.{try} +import luster/session +import luster/store +import luster/web/codec import mist.{ type Connection, type ResponseData, type WebsocketConnection, type WebsocketMessage, Binary, Closed, Custom, Shutdown, Text, } +import luster/web/pages/game +import nakai pub opaque type Message { - Update + Close } -pub opaque type State { - State(self: process.Subject(Message)) +pub opaque type State(record, message) { + State( + socket: process.Subject(Message), + store: process.Subject(store.Message(record)), + registry: process.Subject(chip.Action(Int, message)), + ) } -pub fn start(request: Request(Connection)) -> Response(ResponseData) { +pub fn start( + request: Request(Connection), + session: session.Session(game.Model, message), +) -> Response(ResponseData) { mist.websocket( request: request, - on_init: on_init, + on_init: build_init(session), on_close: on_close, handler: handler, ) } -fn on_init( - _conn: WebsocketConnection, -) -> #(State, Option(process.Selector(Nil))) { - let subject = process.new_subject() - - process.send(subject, Update) - - #( - State(subject), - Some( - process.new_selector() - |> process.selecting(subject, to_custom), - ), - ) -} +fn build_init( + session: session.Session(record, message), +) -> fn(WebsocketConnection) -> + #(State(record, message), Option(process.Selector(Message))) { + fn(_conn) { + let subject = process.new_subject() -fn to_custom(message: Message) -> Nil { - case message { - Update -> Nil + #( + State(subject, session.store, session.registry), + Some( + process.new_selector() + |> process.selecting(subject, function.identity), + ), + ) } } -fn on_close(state: State) -> Nil { - io.println("closing connection:") - io.debug(state) +fn on_close(_state: State(record, message)) -> Nil { + io.println("closing connection") Nil } fn handler( - state: State, + state: State(game.Model, message), conn: WebsocketConnection, - message: WebsocketMessage(Nil), -) -> actor.Next(a, State) { + message: WebsocketMessage(Message), +) -> actor.Next(a, State(game.Model, message)) { case message { - Text(<<"start: ":utf8, rest:bits>>) -> { - let assert Ok(_) = - mist.send_text_frame(conn, <<"started :":utf8, rest:bits>>) + Binary(bits) -> { + let _ = { + use #(session, blob) <- try(split(bits, 36)) + use session <- try(bit_array.to_string(session)) + use message <- try(parse_message(blob)) + use model <- try(store.one(state.store, session)) + + let html = + model + |> game.update(message) + |> function.tap(store.update(state.store, session, _)) + |> game.view() + |> nakai.to_inline_string() + + let _ = mist.send_text_frame(conn, html) + Ok(Nil) + } + actor.continue(state) } - Custom(Nil) -> { - let assert Ok(_) = mist.send_text_frame(conn, <<"update":utf8>>) - process.send_after(state.self, 1000, Update) + Text(_message) -> { actor.continue(state) } - Text(bits) | Binary(bits) -> { - case bit_array.to_string(bits) { - Ok(message) -> io.println("out of bound event: " <> message) - Error(_) -> io.println("out of bound event: malformed bits") - } - - actor.continue(state) + Custom(Close) -> { + actor.Stop(process.Normal) } Closed | Shutdown -> { @@ -87,3 +105,51 @@ fn handler( } } } + +fn parse_message(bits: BitArray) -> Result(game.Message, Nil) { + case bits { + <<"\n\n":utf8, "draw-card":utf8, "\n\n":utf8, json:bytes>> -> { + use player <- try(decode(from: json, using: codec.decoder_player)) + Ok(game.DrawCard(player)) + } + + <<"\n\n":utf8, "select-card":utf8, "\n\n":utf8, json:bytes>> -> { + use card <- try(decode(from: json, using: codec.decoder_card)) + Ok(game.SelectCard(card)) + } + + <<"\n\n":utf8, "play-card":utf8, "\n\n":utf8, json:bytes>> -> { + use slot <- try(decode(from: json, using: codec.decoder_slot)) + Ok(game.PlayCard(slot)) + } + + <<"\n\n":utf8, "popup-toggle":utf8, "\n\n":utf8, _json:bytes>> -> { + Ok(game.ToggleScoring) + } + + _other -> { + Error(Nil) + } + } +} + +fn split(bits: BitArray, at index: Int) -> Result(#(BitArray, BitArray), Nil) { + let size = bit_array.byte_size(bits) + + use slice_l <- try(bit_array.slice(bits, 0, index)) + use slice_r <- try(bit_array.slice(bits, index, size - index)) + + Ok(#(slice_l, slice_r)) +} + +fn decode(from json, using decoder) { + case json.decode_bits(json, decoder) { + Ok(value) -> { + Ok(value) + } + + Error(_) -> { + Error(Nil) + } + } +} diff --git a/src/luster/game/cardfield.gleam b/src/luster/game/cardfield.gleam index 75f3df0..a04e7cb 100644 --- a/src/luster/game/cardfield.gleam +++ b/src/luster/game/cardfield.gleam @@ -169,7 +169,8 @@ pub fn next(state: GameState, action: Action) -> Result(GameState, Errors) { True -> GameState( ..state, - turn: state.turn + 1, + turn: state.turn + + 1, sequence: rotate(state.sequence), phase: Play, ) @@ -313,10 +314,9 @@ fn new_line(of piece: piece) -> Line(piece) { fn new_total_score() -> TotalScore { TotalScore( - columns: list.map( - slots, - fn(_) { #(Score(0, 0, 0, HighCard), Score(0, 0, 0, HighCard)) }, - ), + columns: list.map(slots, fn(_) { + #(Score(0, 0, 0, HighCard), Score(0, 0, 0, HighCard)) + }), totals: list.map(slots, fn(_) { figure_player(0) }), total: #(None, 0), ) @@ -470,32 +470,26 @@ fn calculate_total_score(state: GameState) -> TotalScore { fn calculate_columns(state: GameState) -> List(#(Score, Score)) { let columns = - list.index_map( - slots, - fn(slot, index) { - let assert Ok(#(s1, s2)) = list.at(state.total_score.columns, index) + list.index_map(slots, fn(slot, index) { + let assert Ok(#(s1, s2)) = list.at(state.total_score.columns, index) - let battle = get(state.board.battleline, slot) - let column_p1 = get(battle, Player1) - let column_p2 = get(battle, Player2) + let battle = get(state.board.battleline, slot) + let column_p1 = get(battle, Player1) + let column_p2 = get(battle, Player2) - #(score(column_p1, s1.bonus_flank), score(column_p2, s2.bonus_flank)) - }, - ) + #(score(column_p1, s1.bonus_flank), score(column_p2, s2.bonus_flank)) + }) let flanks = flank_bonuses(columns) let columns = - list.map( - slots, - fn(slot) { - let battle = get(state.board.battleline, slot) - let column_p1 = get(battle, Player1) - let column_p2 = get(battle, Player2) + list.map(slots, fn(slot) { + let battle = get(state.board.battleline, slot) + let column_p1 = get(battle, Player1) + let column_p2 = get(battle, Player2) - #(score(column_p1, 0), score(column_p2, 0)) - }, - ) + #(score(column_p1, 0), score(column_p2, 0)) + }) list.zip(columns, flanks) |> list.map(fn(scores) { @@ -555,18 +549,15 @@ fn flank_bonuses(scores: List(#(Score, Score))) -> List(Option(Player)) { } fn calculate_totals(scores: List(#(Score, Score))) -> List(Int) { - list.map( - scores, - fn(score) { - let #(score_p1, score_p2) = score - - let score_p1 = - score_p1.score + score_p1.bonus_formation + score_p1.bonus_flank - let score_p2 = - score_p2.score + score_p2.bonus_formation + score_p2.bonus_flank - score_p1 - score_p2 - }, - ) + list.map(scores, fn(score) { + let #(score_p1, score_p2) = score + + let score_p1 = + score_p1.score + score_p1.bonus_formation + score_p1.bonus_flank + let score_p2 = + score_p2.score + score_p2.bonus_formation + score_p2.bonus_flank + score_p1 - score_p2 + }) } fn calculate_total(scores: List(Int)) -> Int { diff --git a/src/luster/session.gleam b/src/luster/session.gleam new file mode 100644 index 0000000..425d66d --- /dev/null +++ b/src/luster/session.gleam @@ -0,0 +1,10 @@ +import gleam/erlang/process +import luster/store +import chip + +pub type Session(record, message) { + Session( + store: process.Subject(store.Message(record)), + registry: process.Subject(chip.Action(Int, message)), + ) +} diff --git a/src/luster/store.gleam b/src/luster/store.gleam index 058b999..15cf96a 100644 --- a/src/luster/store.gleam +++ b/src/luster/store.gleam @@ -3,25 +3,22 @@ import gleam/int import gleam/list import gleam/order import gleam/otp/actor +import gleam/bit_array type State(x) { State(counter: Int, records: List(Record(x))) } type Record(x) { - Record(id: Int, value: x) -} - -pub type Errors { - NotFound(id: Int) + Record(id: Int, uuid: String, value: x) } pub opaque type Message(x) { - Create(caller: process.Subject(Int), value: x) - Update(caller: process.Subject(Result(Int, Errors)), id: Int, value: x) - Delete(id: Int) - All(caller: process.Subject(List(#(Int, x)))) - One(caller: process.Subject(Result(x, Errors)), id: Int) + Create(caller: process.Subject(String), value: x) + Update(caller: process.Subject(Result(String, Nil)), uuid: String, value: x) + Delete(uuid: String) + All(caller: process.Subject(List(#(String, x)))) + One(caller: process.Subject(Result(x, Nil)), uuid: String) Stop } @@ -32,24 +29,24 @@ pub fn start() -> Result(Store(x), actor.StartError) { actor.start(State(0, []), handle) } -pub fn create(store: Store(x), value: x) -> Int { +pub fn create(store: Store(x), value: x) -> String { actor.call(store, Create(_, value), 100) } -pub fn update(store: Store(x), id: Int, value: x) -> Result(Int, Errors) { - actor.call(store, Update(_, id, value), 100) +pub fn update(store: Store(x), uuid: String, value: x) -> Result(String, Nil) { + actor.call(store, Update(_, uuid, value), 100) } -pub fn delete(store: Store(x), id: Int) -> Nil { - actor.send(store, Delete(id)) +pub fn delete(store: Store(x), uuid: String) -> Nil { + actor.send(store, Delete(uuid)) } -pub fn all(store: Store(x)) -> List(#(Int, x)) { +pub fn all(store: Store(x)) -> List(#(String, x)) { actor.call(store, All(_), 100) } -pub fn one(store: Store(x), id: Int) -> Result(x, Errors) { - actor.call(store, One(_, id), 100) +pub fn one(store: Store(x), uuid: String) -> Result(x, Nil) { + actor.call(store, One(_, uuid), 100) } pub fn stop(store: Store(x)) -> Nil { @@ -63,15 +60,16 @@ fn handle( case message { Create(caller, value) -> { let counter = state.counter + 1 - let Nil = process.send(caller, counter) - let record = Record(counter, value) + let uuid = uuid4() + let Nil = process.send(caller, uuid) + let record = Record(counter, uuid, value) let state = State(counter, [record, ..state.records]) actor.continue(state) } Update(caller, id, value) -> { - case list.pop(state.records, with_id(_, id)) { + case list.pop(state.records, with_uuid(_, id)) { Ok(#(record, records)) -> { let record = Record(..record, value: value) let state = State(..state, records: [record, ..records]) @@ -81,7 +79,7 @@ fn handle( } Error(Nil) -> { - let Nil = process.send(caller, Error(NotFound(id))) + let Nil = process.send(caller, Error(Nil)) actor.continue(state) } @@ -89,7 +87,7 @@ fn handle( } Delete(id) -> { - case list.pop(state.records, with_id(_, id)) { + case list.pop(state.records, with_uuid(_, id)) { Ok(#(_record, records)) -> { let state = State(..state, records: records) @@ -114,7 +112,7 @@ fn handle( } One(caller, id) -> { - case list.find(state.records, with_id(_, id)) { + case list.find(state.records, with_uuid(_, id)) { Ok(record) -> { let Nil = process.send(caller, Ok(record.value)) @@ -122,7 +120,7 @@ fn handle( } Error(Nil) -> { - let Nil = process.send(caller, Error(NotFound(id))) + let Nil = process.send(caller, Error(Nil)) actor.continue(state) } @@ -135,14 +133,53 @@ fn handle( } } -fn with_id(record: Record(x), id: Int) -> Bool { - record.id == id +fn with_uuid(record: Record(x), uuid: String) -> Bool { + record.uuid == uuid } fn by_id(record_a: Record(x), record_b: Record(x)) -> order.Order { int.compare(record_a.id, record_b.id) } -fn to_tuple(record: Record(x)) -> #(Int, x) { - #(record.id, record.value) +fn to_tuple(record: Record(x)) -> #(String, x) { + #(record.uuid, record.value) +} + +// Found at: https://stackoverflow.com/a/67863695 +fn uuid4() { + let assert <> = + strong_rand_bytes(16) + + let assert <> = << + u0:size(32), + u1:size(16), + 52:size(4), + u2:size(12), + 2:size(2), + u3:size(30), + u4:size(32), + >> + + let assert Ok(uuid) = + format("~8.16.0b-~4.16.0b-~4.16.0b-~2.16.0b~2.16.0b-~12.16.0b", [ + tl, + tm, + thv, + csr, + csl, + n, + ]) + |> to_bit_array() + |> bit_array.to_string() + + uuid } + +@external(erlang, "crypto", "strong_rand_bytes") +fn strong_rand_bytes(size: Int) -> BitArray + +@external(erlang, "io_lib", "format") +fn format(pattern: String, bytes: List(Int)) -> List(String) + +@external(erlang, "erlang", "list_to_binary") +fn to_bit_array(list: List(String)) -> BitArray diff --git a/src/luster/web.gleam b/src/luster/web.gleam index f7dc6c6..939cb13 100644 --- a/src/luster/web.gleam +++ b/src/luster/web.gleam @@ -1,77 +1,53 @@ import gleam/bit_array import gleam/bytes_builder import gleam/erlang -import gleam/erlang/process import gleam/http import gleam/http/request import gleam/http/response -import gleam/int import gleam/string import gleam/uri import luster/events import luster/store import luster/web/pages/game import luster/web/pages/home -import luster/web/pages/layout import mist import nakai import nakai/html - -// --- Middleware and Routing --- // - -// TODO: La alternativa es un evento ShowAlert + redirect -pub type Context(record, html, message) { - Context( - store: store.Store(record), - params: List(#(String, String)), - selector: process.Selector(message), - ) -} +import nakai/html/attrs +import luster/session pub fn router( request: request.Request(mist.Connection), - context: Context(game.Model, html, message), + session: session.Session(game.Model, message), ) -> response.Response(mist.ResponseData) { case request.method, request.path_segments(request) { http.Get, [] -> { - let records = store.all(context.store) + let records = store.all(session.store) home.Model(records) |> home.view() - |> render(with: layout.view) + |> render(with: fn(body) { layout("", body) }) } http.Post, ["battleline"] -> { let model = game.init() - let _id = store.create(context.store, model) + let _ = store.create(session.store, model) redirect("/") } http.Get, ["battleline", id] -> { - let assert Ok(select_id) = int.parse(id) - let assert Ok(model) = store.one(context.store, select_id) - - model - |> game.view() - |> render(with: layout.view) - } - - http.Post, ["battleline", id] -> { - let params = process_form(request) - let assert Ok(select_id) = int.parse(id) - let assert Ok(model) = store.one(context.store, select_id) - let assert Ok(message) = game.decode_message(params) - - let _ = - model - |> game.update(message) - |> store.update(context.store, select_id, _) - - redirect("/battleline/" <> id) + case store.one(session.store, id) { + Ok(model) -> + model + |> game.view() + |> render(with: fn(body) { layout(id, body) }) + + Error(_) -> redirect("/") + } } http.Get, ["events"] -> { - events.start(request) + events.start(request, session) } http.Get, ["assets", ..] -> { @@ -84,6 +60,34 @@ pub fn router( } } +fn layout(session: String, body: html.Node(a)) -> html.Node(a) { + html.Html([], [ + html.Head([ + html.title("Line Poker"), + html.meta([attrs.name("viewport"), attrs.content("width=device-width")]), + html.meta([attrs.name("session"), attrs.content(session)]), + html.link([ + attrs.rel("icon"), + attrs.type_("image/x-icon"), + attrs.defer(), + attrs.href("/assets/favicon.ico"), + ]), + html.link([ + attrs.rel("stylesheet"), + attrs.type_("text/css"), + attrs.defer(), + attrs.href("/assets/styles.css"), + ]), + html.Element( + tag: "script", + attrs: [attrs.src("/assets/script.js"), attrs.defer()], + children: [], + ), + ]), + html.Body([], [body]), + ]) +} + // https://www.iana.org/assignments/media-types/media-types.xhtml type MIME { HTML diff --git a/src/luster/web/codec.gleam b/src/luster/web/codec.gleam new file mode 100644 index 0000000..16ee4ea --- /dev/null +++ b/src/luster/web/codec.gleam @@ -0,0 +1,126 @@ +import gleam/dynamic.{type DecodeError, type Dynamic} +import gleam/int +import gleam/result.{try} +import luster/game/cardfield.{ + type Card, type Player, type Slot, type Suit, Club, Diamond, Heart, Player1, + Player2, Slot1, Slot2, Slot3, Slot4, Slot5, Slot6, Slot7, Slot8, Slot9, Spade, +} + +pub fn encode_player(player: Player) -> String { + case player { + Player1 -> "player-1" + Player2 -> "player-2" + } +} + +pub fn encode_slot(slot: Slot) -> String { + case slot { + Slot1 -> "1" + Slot2 -> "2" + Slot3 -> "3" + Slot4 -> "4" + Slot5 -> "5" + Slot6 -> "6" + Slot7 -> "7" + Slot8 -> "8" + Slot9 -> "9" + } +} + +pub fn encode_rank(rank: Int) -> String { + int.to_string(rank) +} + +pub fn encode_suit(suit: Suit) -> String { + case suit { + Spade -> "♠" + Heart -> "♥" + Diamond -> "♦" + Club -> "♣" + } +} + +pub fn decoder_player(data: Dynamic) -> Result(Player, List(DecodeError)) { + dynamic.field("player", of: decode_player)(data) +} + +pub fn decoder_slot(data: Dynamic) -> Result(Slot, List(DecodeError)) { + dynamic.field("slot", of: decode_slot)(data) +} + +pub fn decoder_card(data: Dynamic) -> Result(Card, List(DecodeError)) { + dynamic.decode2( + cardfield.Card, + dynamic.field("rank", of: decode_rank), + dynamic.field("suit", of: decode_suit), + )(data) +} + +fn decode_player(data: Dynamic) -> Result(Player, List(DecodeError)) { + use string <- try(dynamic.string(data)) + use player <- try(to_player(string)) + Ok(player) +} + +fn decode_slot(data: Dynamic) -> Result(Slot, List(DecodeError)) { + use string <- try(dynamic.string(data)) + use slot <- try(to_slot(string)) + Ok(slot) +} + +fn decode_suit(data: Dynamic) -> Result(Suit, List(DecodeError)) { + use string <- try(dynamic.string(data)) + use suit <- try(to_suit(string)) + Ok(suit) +} + +fn decode_rank(data: Dynamic) -> Result(Int, List(DecodeError)) { + use string <- try(dynamic.string(data)) + use rank <- try(to_rank(string)) + Ok(rank) +} + +fn to_player(string: String) -> Result(Player, List(DecodeError)) { + case string { + "player-1" -> Ok(Player1) + "player-2" -> Ok(Player2) + string -> + Error([dynamic.DecodeError(expected: "player", found: string, path: [])]) + } +} + +fn to_slot(string: String) -> Result(Slot, List(DecodeError)) { + case string { + "1" -> Ok(Slot1) + "2" -> Ok(Slot2) + "3" -> Ok(Slot3) + "4" -> Ok(Slot4) + "5" -> Ok(Slot5) + "6" -> Ok(Slot6) + "7" -> Ok(Slot7) + "8" -> Ok(Slot8) + "9" -> Ok(Slot9) + x -> Error([dynamic.DecodeError(expected: "1..9", found: x, path: [])]) + } +} + +fn to_suit(string: String) -> Result(Suit, List(DecodeError)) { + case string { + "♠" -> Ok(Spade) + "♥" -> Ok(Heart) + "♦" -> Ok(Diamond) + "♣" -> Ok(Club) + string -> + Error([ + dynamic.DecodeError(expected: "♠♥♦♣", found: string, path: []), + ]) + } +} + +fn to_rank(string: String) -> Result(Int, List(DecodeError)) { + case int.parse(string) { + Ok(value) -> Ok(value) + Error(Nil) -> + Error([dynamic.DecodeError(expected: "Number", found: string, path: [])]) + } +} diff --git a/src/luster/web/pages/game.gleam b/src/luster/web/pages/game.gleam index e19a9c5..3d1ada1 100644 --- a/src/luster/web/pages/game.gleam +++ b/src/luster/web/pages/game.gleam @@ -1,9 +1,8 @@ import gleam/option.{type Option, None, Some} -import gleam/string import gleam/pair import gleam/list -import gleam/dict.{type Dict} import gleam/result.{try} +import luster/web/codec import luster/game/cardfield as cf import gleam/int import nakai/html @@ -15,19 +14,16 @@ pub type Model { Model( name: String, alert: Option(Alert), - selected_card: Dict(cf.Player, Option(cf.Card)), + selected_card: Option(cf.Card), toggle_scoring: Bool, gamestate: cf.GameState, ) } -type Event { - Event(id: String, data: List(#(String, String))) -} - pub type Message { - SelectCard(player: cf.Player, card: cf.Card) - Move(action: cf.Action) + DrawCard(player: cf.Player) + SelectCard(card: cf.Card) + PlayCard(slot: cf.Slot) ToggleScoring } @@ -41,77 +37,70 @@ pub fn init() -> Model { Model( name: generate_name(), alert: None, - selected_card: dict.new() - |> dict.insert(cf.Player1, None) - |> dict.insert(cf.Player2, None), + selected_card: None, toggle_scoring: True, gamestate: cf.new(), ) } pub fn update(model: Model, message: Message) -> Model { - case message { - SelectCard(player, card) -> { - case cf.current_phase(model.gamestate) { - cf.Play -> { - let alert = Some(Info("Play card on a column")) - let selected_card = - dict.insert(model.selected_card, player, Some(card)) - Model(..model, alert: alert, selected_card: selected_card) - } - - _other -> { - let alert = to_alert(cf.NotCurrentPhase) - Model(..model, alert: Some(alert)) - } - } + let result = case message { + DrawCard(player) -> { + let message = cf.DrawCard(player) + use gamestate <- try(cf.next(model.gamestate, message)) + Ok(Model(..model, gamestate: gamestate)) } - Move(action) -> { - case cf.next(model.gamestate, action) { - Ok(state) -> { - Model(..model, gamestate: state) - } + SelectCard(card) -> { + Ok(Model(..model, selected_card: Some(card))) + } - Error(error) -> { - Model(..model, alert: Some(to_alert(error))) - } - } + PlayCard(slot) -> { + let player = cf.current_player(model.gamestate) + use card <- try(option.to_result(model.selected_card, cf.NoCardInHand)) + let message = cf.PlayCard(player, slot, card) + use gamestate <- try(cf.next(model.gamestate, message)) + Ok(Model(..model, gamestate: gamestate)) } ToggleScoring -> { - Model(..model, toggle_scoring: !model.toggle_scoring) + Ok(Model(..model, toggle_scoring: !model.toggle_scoring)) } } + + case result { + Ok(model) -> model + Error(error) -> Model(..model, alert: Some(to_alert(error))) + } } pub fn view(model: Model) -> html.Node(a) { let phase = cf.current_phase(model.gamestate) - let end_game_scoring = case model.toggle_scoring { - True -> end_game_scoring(model.gamestate) - False -> html.Nothing - } - - let toggle_event = encode_toggle_scoring() - html.Fragment([ - form(toggle_event, popup(phase == cf.End, end_game_scoring)), view_game_info(model.gamestate), view_alert(model.alert), html.div([attrs.class("board")], [ html.div([attrs.class("deck")], []), html.div([attrs.class("field")], [ - view_hand(model.gamestate, cf.Player2), + html.section([attrs.class("hand")], [ + view_hand(model.gamestate, cf.Player2), + ]), view_score_columns(model.gamestate, cf.Player2), view_slots(model, cf.Player2), view_score_totals(model.gamestate), view_slots(model, cf.Player1), view_score_columns(model.gamestate, cf.Player1), - view_hand(model.gamestate, cf.Player1), + html.section([attrs.class("hand")], [ + view_hand(model.gamestate, cf.Player1), + ]), ]), view_card_pile(model.gamestate), ]), + popup(phase == cf.End, case model.toggle_scoring { + True -> end_game_scoring(model.gamestate) + False -> html.Nothing + }), ]) } @@ -159,12 +148,16 @@ fn view_alert(message: Option(Alert)) -> html.Node(a) { fn view_card_pile(state: cf.GameState) -> html.Node(a) { let size = cf.deck_size(state) - let p1_event = encode_draw_card(cf.Player1) - let p2_event = encode_draw_card(cf.Player2) html.div([attrs.class("deck")], [ - html.section([attrs.class("draw-pile")], [form(p2_event, draw_deck(size))]), - html.section([attrs.class("draw-pile")], [form(p1_event, draw_deck(size))]), + click( + [#("event", "draw-card"), #("player", codec.encode_player(cf.Player2))], + html.section([attrs.class("draw-pile")], [draw_deck(size)]), + ), + click( + [#("event", "draw-card"), #("player", codec.encode_player(cf.Player1))], + html.section([attrs.class("draw-pile")], [draw_deck(size)]), + ), ]) } @@ -175,7 +168,7 @@ fn view_score_totals(state: cf.GameState) -> html.Node(a) { use score <- list.map(totals) case score { #(Some(player), total) -> { - let player = encode_player(player) + let player = codec.encode_player(player) let score = int.to_string(total) html.div([attrs.class("score" <> " " <> player)], [ @@ -200,7 +193,7 @@ fn view_score_columns(state: cf.GameState, player: cf.Player) -> html.Node(a) { cf.Player2 -> list.map(columns, pair.second) } - let player = encode_player(player) + let player = codec.encode_player(player) html.section([attrs.class("scores")], { use score <- list.map(scores) @@ -238,43 +231,32 @@ fn view_score_columns(state: cf.GameState, player: cf.Player) -> html.Node(a) { fn view_hand(state: cf.GameState, player: cf.Player) -> html.Node(a) { let hand = cf.player_hand(state, of: player) - html.section( - [attrs.class("hand")], + html.Fragment( list.map(hand, fn(card) { - let event = encode_select_card(player, card) - form(event, card_front(card)) + click( + [ + #("event", "select-card"), + #("suit", codec.encode_suit(card.suit)), + #("rank", codec.encode_rank(card.rank)), + ], + card_front(card), + ) }), ) } fn view_slots(model: Model, player: cf.Player) -> html.Node(a) { let columns = cf.columns(model.gamestate, player) - let assert Ok(selected_card) = dict.get(model.selected_card, player) - let player_class = encode_player(player) + let class = attrs.class("slot" <> " " <> codec.encode_player(player)) html.section([attrs.class("slots")], { use slot_column <- list.map(columns) let #(slot, column) = slot_column - case selected_card { - Some(card) -> { - let event = encode_play_card(player, slot, card) - form( - event, - html.div( - [attrs.class("slot" <> " " <> player_class)], - list.map(column, card_front), - ), - ) - } - - None -> { - html.div( - [attrs.class("slot" <> " " <> player_class)], - list.map(column, card_front), - ) - } - } + click( + [#("event", "play-card"), #("slot", codec.encode_slot(slot))], + html.div([class], list.map(column, fn(card) { card_front(card) })), + ) }) } @@ -282,11 +264,10 @@ fn view_slots(model: Model, player: cf.Player) -> html.Node(a) { fn card_front(card: cf.Card) -> html.Node(a) { let utf = suit_utf(card.suit) + let rank = codec.encode_rank(card.rank) let color = suit_color(card.suit) - let rank = int.to_string(card.rank) - - html.div([attrs.class("card front clouds " <> color)], [ + html.div([attrs.class("card front " <> color)], [ html.div([attrs.class("upper-left")], [ html.p_text([], rank), html.p_text([], utf), @@ -299,10 +280,6 @@ fn card_front(card: cf.Card) -> html.Node(a) { ]) } -fn card_back() -> html.Node(a) { - html.div([attrs.class("card back sparkle")], []) -} - fn draw_deck(size: Int) -> html.Node(a) { let count = case size { x if x > 48 -> 13 @@ -322,7 +299,11 @@ fn draw_deck(size: Int) -> html.Node(a) { } let card = card_back() - html.div([], list.repeat(card, count)) + html.Fragment(list.repeat(card, count)) +} + +fn card_back() -> html.Node(a) { + html.div([attrs.class("card back sparkle")], []) } fn alert(alert: Alert) -> html.Node(a) { @@ -549,32 +530,30 @@ fn winner(total: #(Option(cf.Player), Int)) -> html.Node(a) { ]) } -// --- View HTML Helpers --- // - -fn form(event: Event, markup: html.Node(a)) -> html.Node(a) { - let input = list.map(event.data, hidden_input) - let button = button(event.id, markup) - - html.form( - [attrs.id(event.id), attrs.method("post")], - list.append(input, [button]), - ) +fn popup(display: Bool, markup: html.Node(a)) -> html.Node(a) { + case display { + True -> + click( + [#("event", "popup-toggle")], + html.div([attrs.class("popup")], [markup]), + ) + False -> html.Nothing + } } -fn hidden_input(param: #(String, String)) -> html.Node(a) { - html.input([attrs.type_("hidden"), attrs.name(param.0), attrs.value(param.1)]) +// --- View HTML Helpers --- // + +fn click(params: List(#(String, String)), markup: html.Node(a)) -> html.Node(a) { + let dataset = dataset(params) + html.div(dataset, [markup]) } -fn button(form_id: String, markup: html.Node(a)) -> html.Node(a) { - let form_attr = attrs.Attr(name: "form", value: form_id) - html.button([form_attr], [markup]) +fn dataset(params: List(#(String, String))) -> List(attrs.Attr(a)) { + list.map(params, data_attr) } -fn popup(display: Bool, markup: html.Node(a)) -> html.Node(a) { - case display { - True -> html.div([attrs.class("popup")], [markup]) - False -> html.Nothing - } +fn data_attr(param: #(String, String)) -> attrs.Attr(a) { + attrs.Attr(name: "data-" <> param.0, value: param.1) } // --- Helpers --- // @@ -602,198 +581,3 @@ fn generate_name() -> String { adjective <> " " <> subject } - -// --- Encoders and Decoders --- // - -pub fn decode_message( - params: List(#(String, String)), -) -> Result(Message, String) { - case params { - [#("action", "draw-card"), #("player", player)] -> { - use player <- try(decode_player(player)) - Ok(Move(cf.DrawCard(player))) - } - - [ - #("action", "play-card"), - #("player", player), - #("slot", slot), - #("card_rank", rank), - #("card_suit", suit), - ] -> { - use player <- try(decode_player(player)) - use slot <- try(decode_slot(slot)) - use suit <- try(decode_card_suit(suit)) - use rank <- try(decode_card_rank(rank)) - Ok(Move(cf.PlayCard(player, slot, cf.Card(rank, suit)))) - } - - [ - #("action", "select-card"), - #("player", player), - #("card_rank", rank), - #("card_suit", suit), - ] -> { - use player <- try(decode_player(player)) - use suit <- try(decode_card_suit(suit)) - use rank <- try(decode_card_rank(rank)) - Ok(SelectCard(player, cf.Card(rank, suit))) - } - - [#("action", "toggle-scoring")] -> { - Ok(ToggleScoring) - } - - _other -> Error("Malformed message") - } -} - -fn encode_draw_card(player: cf.Player) -> Event { - let action = "draw-card" - let player = encode_player(player) - let id = string.join([action, player], "-") - - Event(id: id, data: [#("action", action), #("player", player)]) -} - -fn encode_select_card(player: cf.Player, card: cf.Card) -> Event { - let action = "select-card" - let player = encode_player(player) - let rank = encode_card_rank(card.rank) - let suit = encode_card_suit(card.suit) - let id = string.join([action, player, rank, suit], "-") - - Event(id: id, data: [ - #("action", "select-card"), - #("player", player), - #("card_rank", rank), - #("card_suit", suit), - ]) -} - -fn encode_play_card(player: cf.Player, slot: cf.Slot, card: cf.Card) -> Event { - let action = "play-card" - let player = encode_player(player) - let slot = encode_slot(slot) - let id = string.join([action, player, slot], "-") - - Event(id: id, data: [ - #("action", "play-card"), - #("player", player), - #("slot", slot), - #("card_rank", encode_card_rank(card.rank)), - #("card_suit", encode_card_suit(card.suit)), - ]) -} - -fn encode_toggle_scoring() -> Event { - let action = "toggle-scoring" - - Event(id: action, data: [#("action", action)]) -} - -const encoding_for_player = [ - #(cf.Player1, "player-1"), - #(cf.Player2, "player-2"), -] - -fn encode_player(value: cf.Player) -> String { - encoding_for_player - |> encode(value) -} - -fn decode_player(value: String) -> Result(cf.Player, String) { - encoding_for_player - |> decode(value) - |> result.replace_error("unable to decode player") -} - -const encoding_for_slot = [ - #(cf.Slot1, "slot-1"), - #(cf.Slot2, "slot-2"), - #(cf.Slot3, "slot-3"), - #(cf.Slot4, "slot-4"), - #(cf.Slot5, "slot-5"), - #(cf.Slot6, "slot-6"), - #(cf.Slot7, "slot-7"), - #(cf.Slot8, "slot-8"), - #(cf.Slot9, "slot-9"), -] - -fn encode_slot(value: cf.Slot) -> String { - encoding_for_slot - |> encode(value) -} - -fn decode_slot(value: String) -> Result(cf.Slot, String) { - encoding_for_slot - |> decode(value) - |> result.replace_error("unable to decode slot") -} - -const encoding_for_card_rank = [ - #(1, "1"), - #(2, "2"), - #(3, "3"), - #(4, "4"), - #(5, "5"), - #(6, "6"), - #(7, "7"), - #(8, "8"), - #(9, "9"), - #(10, "10"), - #(11, "11"), - #(12, "12"), - #(13, "13"), -] - -fn encode_card_rank(value: Int) -> String { - encoding_for_card_rank - |> encode(value) -} - -fn decode_card_rank(value: String) -> Result(Int, String) { - encoding_for_card_rank - |> decode(value) - |> result.replace_error("unable to decode card rank") -} - -const encoding_for_card_suit = [ - #(cf.Spade, "spade"), - #(cf.Heart, "heart"), - #(cf.Diamond, "diamond"), - #(cf.Club, "club"), -] - -fn encode_card_suit(value: cf.Suit) -> String { - encoding_for_card_suit - |> encode(value) -} - -fn decode_card_suit(value: String) -> Result(cf.Suit, String) { - encoding_for_card_suit - |> decode(value) - |> result.replace_error("unable to decode card suit") -} - -fn encode(encoding: List(#(x, y)), value: x) -> y { - case key_find(encoding, value) { - Ok(value) -> value - Error(Nil) -> panic as "unable to encode value" - } -} - -fn decode(encoding: List(#(x, y)), value: y) -> Result(x, Nil) { - encoding - |> list.map(pair.swap) - |> key_find(value) -} - -fn key_find(pairs: List(#(x, y)), key: x) -> Result(y, Nil) { - list.find_map(pairs, fn(pair) { - case pair.0 == key { - True -> Ok(pair.1) - False -> Error(pair.1) - } - }) -} diff --git a/src/luster/web/pages/home.gleam b/src/luster/web/pages/home.gleam index 169c334..3f804e4 100644 --- a/src/luster/web/pages/home.gleam +++ b/src/luster/web/pages/home.gleam @@ -7,7 +7,7 @@ import nakai/html/attrs // --- Elmish Lobby --- // pub type Model(r) { - Model(games: List(#(Int, r))) + Model(games: List(#(String, r))) } pub fn view(model: Model(r)) -> html.Node(a) { @@ -21,15 +21,12 @@ pub fn view(model: Model(r)) -> html.Node(a) { fn create_game_form() -> html.Node(a) { // TODO: This form makes me thing that the full HTTP request needs to be encoded - html.form( - [attrs.method("post"), attrs.action("/battleline")], - [html.input([attrs.type_("submit"), attrs.value("Create new game")])], - ) + html.form([attrs.method("post"), attrs.action("/battleline")], [ + html.input([attrs.type_("submit"), attrs.value("Create new game")]), + ]) } -fn listed_lobby_game(id: Int) -> html.Node(a) { - let id = int.to_string(id) - +fn listed_lobby_game(id: String) -> html.Node(a) { let anchor = html.a_text([attrs.href("/battleline/" <> id)], nth(id)) html.li([], [anchor]) } diff --git a/src/luster/web/pages/layout.gleam b/src/luster/web/pages/layout.gleam deleted file mode 100644 index 4e89cec..0000000 --- a/src/luster/web/pages/layout.gleam +++ /dev/null @@ -1,29 +0,0 @@ -import nakai/html -import nakai/html/attrs - -pub fn view(body: html.Node(a)) -> html.Node(a) { - html.Html([], [ - html.Head([ - html.title("Line Poker"), - html.meta([attrs.name("viewport"), attrs.content("width=device-width")]), - html.link([ - attrs.rel("icon"), - attrs.type_("image/x-icon"), - attrs.defer(), - attrs.href("/assets/favicon.ico"), - ]), - html.link([ - attrs.rel("stylesheet"), - attrs.type_("text/css"), - attrs.defer(), - attrs.href("/assets/styles.css"), - ]), - html.Element( - tag: "script", - attrs: [attrs.src("/assets/script.js"), attrs.defer()], - children: [], - ), - ]), - html.Body([], [body]), - ]) -}