Player picking

This commit is contained in:
Lett Osprey 2026-04-05 17:12:08 +02:00
parent 5663b61906
commit 987e2b5576
3 changed files with 226 additions and 80 deletions

View file

@ -1,3 +1,4 @@
import gleam/dynamic/decode
import gleam/int
import gleam/json
import gleam/list
@ -12,6 +13,7 @@ import lustre/element/html
import lustre/event
import plinth/browser/document
import plinth/browser/element as plinth_element
import rsvp
import shared.{type Room}
pub fn main() {
@ -25,97 +27,164 @@ pub fn main() {
|> result.unwrap([])
let app = lustre.application(init, update, view)
let assert Ok(_) = lustre.start(app, "#app", initial_items)
let assert Ok(_) = lustre.start(app, "#app", #(initial_items, None))
Nil
}
type Model {
Model(
rooms: List(Room),
name: Option(String),
pin: Option(String),
typed: String,
)
Model(rooms: List(Room), state: State, ohno: Option(String))
}
fn init(items: List(Room)) -> #(Model, Effect(State)) {
let model = Model(rooms: items, name: None, pin: None, typed: "")
fn init(initial: #(List(Room), Option(String))) -> #(Model, Effect(Msg)) {
let #(rooms, ohno) = initial
let model = Model(rooms:, state: Empty, ohno:)
#(model, effect.none())
}
type State {
Empty
EnterPin(room: String, pin: String)
AwaitPlayers(room: String, pin: String)
PickPlayer(room: String, pin: String, players: List(String))
}
type Msg {
Initialize
SelectRoom(String)
Initial
KeyIn(String)
SelectPlayer(String)
KeyPin(String)
Players(Result(String, rsvp.Error))
}
fn update(model: Model, msg: State) -> #(Model, Effect(msg)) {
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg {
Initial ->
case model.name {
_ -> #(model, effect.none())
Initialize -> init(#(model.rooms, None))
SelectRoom(room) -> #(
Model(..model, state: EnterPin(room:, pin: "")),
effect.none(),
)
KeyPin(pin) ->
case model.state {
EnterPin(room, _) -> {
let decode_answer = {
use id <- decode.field("id", decode.string)
// use text <- decode.field("text", decode.string)
decode.success(id)
}
SelectRoom(text) -> {
#(Model(..model, name: Some(text)), effect.none())
// let decode_list = {
// decode.list(decode.string)
// }
case string.length(pin) < 4 {
False -> {
#(
Model(
..model,
state: PickPlayer(room:, pin:, players: ["Player a", "Player b", "Player c", "Player d", "Player e", "Player f"]),
),
effect.none(),
)
// Model(..model, state: AwaitPlayers(room:, pin:)),
// rsvp.post(
// "http://localhost:1234/api/room/players",
// json.object([#("id", json.string("1234"))]),
// rsvp.expect_json(decode_answer, Players),
// ),
// )
}
KeyIn(newpin) -> {
case string.length(newpin) < 4 {
False -> #(Model(..model, pin: Some(newpin)), effect.none())
True -> #(Model(..model, typed: newpin), effect.none())
True -> #(
Model(..model, state: EnterPin(room:, pin:)),
effect.none(),
)
}
}
_ ->
init(#(model.rooms, Some("(EnterPin) Invalid state, starting over")))
// Invalid model state, start over
}
Players(Ok(players)) -> {
echo "got players"
case model.state {
AwaitPlayers(room:, pin:) -> #(
Model(..model, state: PickPlayer(room:, pin:, players: [players])),
effect.none(),
)
_ -> init(#(model.rooms, Some("Invalid state, expected AwaitPlayers")))
// invalid state, start over
}
}
Players(Error(x)) ->
init(#(
model.rooms,
Some("Error fetching players " <> decode_rsvp_error(x)),
))
SelectPlayer(x) -> init(#(model.rooms, Some("Players " <> x)))
}
}
fn view(model: Model) -> Element(State) {
case model.name, model.pin {
None, _ ->
html.div([], [
html.div([class("terminal-header")], [
html.div([class("terminal-status")], [
html.span([class("status-blink")], [html.text("")]),
html.text(" SYSTEM READY"),
html.span([class("ml-8")], [
html.text("<< Please Log On to use QuizTerm. >>"),
]),
]),
]),
view_room_list(model.rooms),
])
Some(_), None -> {
html.div([], [
html.div([class("terminal-header")], [
html.div([class("terminal-status")], [
html.span([class("status-blink")], [html.text("")]),
html.text(" SYSTEM READY"),
html.span([class("ml-8")], [
html.text("<< Please Log On to use QuizTerm. >>"),
]),
]),
]),
pin(),
])
}
Some(_), Some(_) -> {
html.div([], [
html.div([class("terminal-header")], [
html.div([class("terminal-status")], [
html.span([class("status-blink")], [html.text("")]),
html.text(" SYSTEM READY"),
html.span([class("ml-8")], [
html.text("<< Please Log On to use QuizTerm. >>"),
]),
]),
]),
html.text("FETCHING USERS FOR ROOM"),
])
}
fn decode_rsvp_error(rsvp_error: rsvp.Error) {
case rsvp_error {
rsvp.BadBody -> "Bad body"
rsvp.BadUrl(x) -> "Bad url"
rsvp.HttpError(x) -> "Http error"
rsvp.JsonError(x) -> "Json error" <> decode_decode_error(x)
rsvp.NetworkError -> "Network error"
rsvp.UnhandledResponse(_) -> "Unhandled response"
}
}
fn pin() -> Element(State) {
fn decode_decode_error(json_error: json.DecodeError) -> String {
case json_error {
json.UnableToDecode(x) ->
"[unable to decode"
<> string.concat(
list.map(x, fn(x) { "(" <> decode_ddecode_error(x) <> ")" }),
)
<> "]"
json.UnexpectedSequence(s) -> "[unexpected sequence " <> s <> "]"
json.UnexpectedByte(x) -> "[unexpected byte " <> x <> "]"
json.UnexpectedEndOfInput -> "[unexpected end of input]"
}
}
fn decode_ddecode_error(decode_error: decode.DecodeError) -> String {
case decode_error {
decode.DecodeError(expected:, found:, path:) ->
"(e: " <> expected <> ", f: " <> found <> ", p: " <> string.concat(path)
}
}
fn view(model: Model) -> Element(Msg) {
html.div([], [
html.div([class("terminal-header")], [
html.div([class("terminal-status")], [
html.span([class("status-blink")], [html.text("")]),
html.div([], [
html.text(" SYSTEM READY"),
]),
html.div([], [
case model.ohno {
None -> element.none()
Some(x) -> html.h3([], [html.text("Fail: " <> x)])
},
]),
html.span([class("ml-8")], [
html.text("<< Please Log On to use QuizTerm. >>"),
]),
]),
]),
case model.state {
Empty -> view_room_list(model.rooms)
EnterPin(_, _) -> pin()
AwaitPlayers(_, _) -> html.text("FETCHING USERS FOR ROOM")
PickPlayer(_, _, players) -> view_player_list(players)
},
])
}
fn pin() -> Element(Msg) {
html.div([attribute.class("terminal-section")], [
html.div([attribute.class("terminal-label mb-4")], [
html.text("Select room to play in"),
@ -124,14 +193,14 @@ fn pin() -> Element(State) {
html.text("Enter PIN code for room"),
html.input([
attribute.type_("password"),
event.on_input(KeyIn),
event.on_input(KeyPin),
attribute.autofocus(True),
]),
]),
])
}
fn view_room_list(items: List(Room)) -> Element(State) {
fn view_room_list(items: List(Room)) -> Element(Msg) {
html.div([attribute.class("terminal-section")], [
html.div([attribute.class("terminal-label mb-4")], [
html.text("Select room to play in"),
@ -140,14 +209,42 @@ fn view_room_list(items: List(Room)) -> Element(State) {
[] -> [html.text("No items in your list yet.")]
_ -> {
list.index_map(items, fn(item, index) {
content_cell(index, item, SelectRoom)
room_cell(index, item, SelectRoom)
})
}
}),
])
}
fn content_cell(
fn view_player_list(items: List(String)) -> Element(Msg) {
html.div([attribute.class("terminal-section")], [
html.div([attribute.class("terminal-label mb-4")], [
html.text("Select room to play in"),
]),
html.div([attribute.class("participants-grid")], case items {
[] -> [html.text("No items in your list yet.")]
_ -> {
list.index_map(items, fn(item, index) {
player_cell(index, item, SelectPlayer)
})
}
}),
])
}
fn player_cell(
number: Int,
player: String,
on_click: fn(String) -> msg,
) -> Element(msg) {
html.div([class("participant-login"), event.on_click(on_click(player))], [
html.div([class("participant-name")], [
html.text("" <> "[#" <> int.to_string(number) <> "] " <> player),
]),
])
}
fn room_cell(
number: Int,
room: Room,
on_click: fn(String) -> msg,

View file

@ -28,8 +28,8 @@ pub fn initialize(state_handler: Started(Subject(StateControl))) {
state.rooms |> list.key_find(id)
{
Error(_) -> {
// Prevent overflowing server with rooms, set max 200
case list.length(state.rooms) < 200 {
// Prevent overflowing server with rooms, set max 50
case list.length(state.rooms) < 50 {
True -> {
// Room not found (not really an error case), create it.
let name = process.new_name("quiz-registry" <> id)

View file

@ -4,7 +4,9 @@ import gleam/dynamic/decode
import gleam/erlang/process.{type Subject}
import gleam/http
import gleam/int
import gleam/json
import gleam/list
import gleam/option
import gleam/otp/actor.{type Started}
import shared/message.{type ClientsServer, type RoomControl, type StateControl}
import web/handlers/serve.{board, main_html, room, slow, status_head}
@ -17,7 +19,8 @@ pub fn handle_request(
) -> Response {
use req <- middleware(req)
case wisp.path_segments(req) {
["api", ..path] -> handle_api(state_handler, req, path)
["api", "room", ..path] -> handle_room_api(room_handler, req, path)
["api", ..path] -> handle_admin_api(state_handler, req, path)
_ -> handle_html(room_handler, req)
}
}
@ -38,7 +41,21 @@ fn handle_html(
|> main_html
}
fn handle_api(
fn handle_room_api(
room_handler: Started(Subject(RoomControl(ClientsServer))),
req: Request,
path: List(String),
) {
use json <- wisp.require_json(req)
case req.method, path {
http.Post, ["players"] -> fetch_players(room_handler, json)
_, _ -> #(404, "bad api path", "Resource not found")
}
|> serve.create_json_response
}
fn handle_admin_api(
actor: Started(Subject(StateControl)),
req: Request,
path: List(String),
@ -58,15 +75,15 @@ fn handle_api(
decode_index_to_text(actor, json, message.SetQuestion)
http.Post, ["answers"] ->
decode_index_to_text(actor, json, message.SetAnswer)
_, _ -> #(404, "bad api apth","Resource not found")
_, _ -> #(404, "bad api path", "Resource not found")
}
False -> {
#(401, "invalid api key","unauthorized")
#(401, "invalid api key", "unauthorized")
}
}
}
Error(_) -> {
#(401, "missing api key","unauthorized")
#(401, "missing api key", "unauthorized")
}
}
|> serve.create_json_response
@ -85,7 +102,35 @@ fn decode_info(
actor.send(actor.data, info)
#(200, "Updated info", "Updated info")
}
Error(_) -> #(400, "Unable to update info","bad request")
Error(_) -> #(400, "Unable to update info", "bad request")
}
}
fn fetch_players(
room_handler: Started(Subject(RoomControl(ClientsServer))),
json_string: decode.Dynamic,
) {
let decode_uri = {
use id <- decode.field("id", decode.string)
//use key <- decode.field("key", decode.string)
decode.success(message.FetchRoom(id, _))
}
case decode.run(json_string, decode_uri) {
Ok(room) -> {
case actor.call(room_handler.data, 1000, room) {
option.Some(#(_, player_handler)) -> #(
200,
json.to_string(json.object([#("id", json.string("10"))])),
json.to_string(json.object([#("id", json.string("10"))])),
)
option.None -> #(
404,
"Room not found, or key invalid",
"resource not found",
)
}
}
Error(fault) -> #(400, "Unable to fetch players", "bad request")
}
}
@ -105,9 +150,13 @@ fn decode_index_to_text(
list.each(answers, fn(answer_question) {
actor.send(actor.data, answer_question)
})
#(200, "imported " <> int.to_string(list.length(answers)) <> " items.","imported " <> int.to_string(list.length(answers)) <> " items.")
#(
200,
"imported " <> int.to_string(list.length(answers)) <> " items.",
"imported " <> int.to_string(list.length(answers)) <> " items.",
)
}
Error(_) -> #(400, "Failed to import","bad request")
Error(_) -> #(400, "Failed to import", "bad request")
}
}