Most work done on single player game

This commit is contained in:
Lett Osprey 2026-04-12 21:19:28 +02:00
parent 584b1c9ef9
commit 09bf741997
8 changed files with 230 additions and 143 deletions

View file

@ -1,3 +1,5 @@
import gleam/bit_array
import gleam/crypto
import gleam/erlang/process.{type Subject} import gleam/erlang/process.{type Subject}
import gleam/int import gleam/int
import gleam/list import gleam/list
@ -15,7 +17,7 @@ type State {
State( State(
question_number: Int, question_number: Int,
// id, (name (question#, answer_attempt) // id, (name (question#, answer_attempt)
slow_answers: List(#(String, #(String, List(#(Int, String))))), slow_answers: List(#(String, #(String, List(#(String, String))))),
// int in #pair: ping counted since response back. // int in #pair: ping counted since response back.
name_answers: List(#(String, #(Int, AnswerStatus))), name_answers: List(#(String, #(Int, AnswerStatus))),
hide_answers: Bool, hide_answers: Bool,
@ -92,22 +94,25 @@ pub fn initialize(
fetch_players(state.slow_answers, subject) fetch_players(state.slow_answers, subject)
state state
} }
message.AddPlayer(name:, subject:) -> { message.AddPlayer(name) ->
let _ = add_player(name, subject) State(..state, slow_answers: add_player(name, state.slow_answers))
state
}
} }
|> actor.continue() |> actor.continue()
}) })
|> actor.start |> actor.start
} }
fn add_player(_name: String, _subject: Subject(Result(String, String))) { fn add_player(name: String, players: List(#(String, #(String, List(#(_, _)))))) {
#(200, "ok", "ok") let id =
bit_array.base64_encode(crypto.hash(crypto.Sha256, <<name:utf8>>), True)
case list.key_find(players, id) {
Error(_) -> [#(id, #(name, [])), ..players]
Ok(_) -> players
}
} }
fn fetch_players( fn fetch_players(
players: List(#(String, #(String, List(#(Int, String))))), players: List(#(String, #(String, List(#(_, String))))),
subject: Subject(List(#(String, String))), subject: Subject(List(#(String, String))),
) { ) {
actor.send( actor.send(
@ -246,7 +251,7 @@ fn combine_lists(state: State) {
let #(_, #(name, answers)) = name_answers let #(_, #(name, answers)) = name_answers
list.filter_map(answers, fn(number_answer) { list.filter_map(answers, fn(number_answer) {
let #(answer_number, answer) = number_answer let #(answer_number, answer) = number_answer
case state.question_number == answer_number { case int.to_string(state.question_number) == answer_number {
True -> { True -> {
Ok( Ok(
User(name, 0, case state.hide_answers { User(name, 0, case state.hide_answers {

View file

@ -35,7 +35,7 @@ pub fn serve(
pub fn serve_slow( pub fn serve_slow(
request: Request(Connection), request: Request(Connection),
component: lustre.App(#(List(#(Int, String)), message.ClientsServer), model, msg), component: lustre.App(#(List(#(String, String)), message.ClientsServer), model, msg),
id: String, id: String,
roomhandler: actor.Started(Subject(message.RoomControl)), roomhandler: actor.Started(Subject(message.RoomControl)),
statehandler: actor.Started(Subject(message.StateControl)), statehandler: actor.Started(Subject(message.StateControl)),

View file

@ -1,3 +1,4 @@
import gleam/int
import gleam/list import gleam/list
import gleam/option.{type Option, None, Some} import gleam/option.{type Option, None, Some}
import gleam/otp/actor import gleam/otp/actor
@ -63,7 +64,7 @@ pub fn initialize() {
subject, subject,
list.map(state.questions, fn(x) { list.map(state.questions, fn(x) {
let #(i, #(q, _)) = x let #(i, #(q, _)) = x
#(i, q) #(int.to_string(i), q)
}), }),
) )
state state

View file

@ -14,9 +14,9 @@ pub type NotifyServer {
PurgePlayers PurgePlayers
GiveName(name: String) GiveName(name: String)
GiveAnswer(name: String, answer: Option(String)) GiveAnswer(name: String, answer: Option(String))
GiveSingleAnswer(id: String, question: Int, answer: String) GiveSingleAnswer(id: String, question: String, answer: String)
FetchPlayers(subject: Subject(List(#(String, String)))) FetchPlayers(subject: Subject(List(#(String, String))))
AddPlayer(name: String, subject: Subject(Result(String, String))) AddPlayer(String)
} }
pub type StateControl { pub type StateControl {
@ -24,7 +24,7 @@ pub type StateControl {
SetAnswer(id: Int, answer: String) SetAnswer(id: Int, answer: String)
SetInfo(url: String) SetInfo(url: String)
FetchQuestion(id: Int, subject: Subject(Option(String))) FetchQuestion(id: Int, subject: Subject(Option(String)))
FetchQuestions(subject: Subject(List(#(Int, String)))) FetchQuestions(subject: Subject(List(#(String, String))))
} }
pub type Room { pub type Room {

View file

@ -1,4 +1,7 @@
import components.{click_cell} import components.{click_cell, click_cell_pair}
import gleam/bit_array
import gleam/crypto
import gleam/dynamic/decode
import gleam/erlang/process.{type Subject} import gleam/erlang/process.{type Subject}
import gleam/int import gleam/int
import gleam/list import gleam/list
@ -9,13 +12,14 @@ import lustre/attribute.{class}
import lustre/effect.{type Effect} import lustre/effect.{type Effect}
import lustre/element.{type Element} import lustre/element.{type Element}
import lustre/element/html import lustre/element/html
import lustre/element/keyed
import lustre/event
import lustre/server_component
import shared/message.{type NotifyClient, type NotifyServer} import shared/message.{type NotifyClient, type NotifyServer}
import web/components/shared.{ import web/components/shared
step_prompt, view_input, view_named_input, view_named_keyed_input, view_yes_no,
}
pub fn component() -> lustre.App( pub fn component() -> lustre.App(
#(List(#(Int, String)), message.ClientsServer), #(List(#(String, String)), message.ClientsServer),
Model, Model,
Msg, Msg,
) { ) {
@ -26,14 +30,14 @@ pub opaque type Model {
Model( Model(
state: Msg, state: Msg,
players: List(#(String, String)), players: List(#(String, String)),
player_id: Option(String), player: Option(#(String, String)),
answers: List(#(Int, #(String, String))), answers: List(#(String, #(String, String))),
handler: Started(Subject(NotifyServer)), handler: Started(Subject(NotifyServer)),
) )
} }
fn init( fn init(
start_args: #(List(#(Int, String)), message.ClientsServer), start_args: #(List(#(String, String)), message.ClientsServer),
) -> #(Model, Effect(Msg)) { ) -> #(Model, Effect(Msg)) {
let #(answers, handlers) = start_args let #(answers, handlers) = start_args
let #(_registry, handler) = handlers let #(_registry, handler) = handlers
@ -42,11 +46,7 @@ fn init(
// "question number" -> #("question text", "users answer" array // "question number" -> #("question text", "users answer" array
// with blank user answers. // with blank user answers.
let initial_array = let initial_array =
list.filter(answers, fn(x) { list.map(answers, fn(x) {
let #(i, _) = x
i <= 14 && i >= 0
})
|> list.map(fn(x) {
let #(a, b) = x let #(a, b) = x
#(a, #(b, "")) #(a, #(b, ""))
}) })
@ -65,68 +65,78 @@ fn init(
pub opaque type Msg { pub opaque type Msg {
Initial Initial
PickedName(name: String) PickedPlayer(player: Option(#(String, String)))
SharedMessage(message: NotifyClient) SharedMessage(message: NotifyClient)
ReceiveName(message: String) ReceiveName(name: Option(String))
AcceptName(accept: Option(String)) AcceptPlayer(accept: Option(#(String, String)))
PickQuestion PickQuestion
PickedQuestion(question: String) PickedQuestion(question: Option(#(String, String)))
GiveAnswer(question: String, answer: String) GiveAnswer(question: #(String, String), answer: Option(String))
} }
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg { case msg {
Initial -> #(Model(..model, state: msg), effect.none()) Initial -> #(Model(..model, state: msg), effect.none())
PickedName(player_id) -> #( PickedPlayer(player) -> #(
Model(..model, player_id: Some(player_id), state: PickQuestion), case player {
Some(player) -> Model(..model, state: AcceptPlayer(Some(player)))
None -> Model(..model, state: ReceiveName(None))
},
effect.none(), effect.none(),
) )
SharedMessage(_) -> #(model, effect.none()) SharedMessage(_) -> #(model, effect.none())
AcceptName(None) -> #( ReceiveName(Some(name)) -> {
Model(Initial, model.players, None, [], model.handler), let id =
effect.none(), bit_array.base64_encode(crypto.hash(crypto.Sha256, <<name:utf8>>), True)
) #(Model(..model, state: AcceptPlayer(Some(#(id, name)))), effect.none())
AcceptName(Some(player_id)) -> { }
AcceptPlayer(Some(player)) -> {
let #(_, player_name) = player
actor.send(model.handler.data, message.AddPlayer(player_name))
#( #(
Model(..model, player_id: Some(player_id), state: PickQuestion), Model(..model, player: Some(player), state: PickQuestion),
effect.none(), effect.none(),
) )
} }
PickQuestion -> #(model, effect.none()) PickedQuestion(Some(question)) -> {
PickedQuestion(question) -> { #(
#(Model(..model, state: GiveAnswer(question, "")), effect.none()) Model(..model, state: GiveAnswer(question:, answer: None)),
effect.none(),
)
} }
GiveAnswer(question, answer) -> { GiveAnswer(question, Some(answer)) -> {
let assert Some(player_id) = model.player_id let #(question, _) = question
case int.parse(question) { let assert Some(#(player_id, _)) = model.player
Ok(question) -> { actor.send(
actor.send( model.handler.data,
model.handler.data, message.GiveSingleAnswer(id: player_id, question:, answer:),
message.GiveSingleAnswer(id: player_id, question:, answer:), )
) let new_value = case list.key_find(model.answers, question) {
let new_value = case list.key_find(model.answers, question) { Ok(pair) -> {
Ok(pair) -> { let #(a, _) = pair
let #(a, _) = pair #(a, answer)
#(a, answer)
}
Error(_) -> #("", answer)
}
#(
Model(
..model,
state: PickQuestion,
answers: list.key_set(model.answers, question, new_value),
),
effect.none(),
)
}
_ -> {
echo "bad index"
#(model, effect.none())
} }
Error(_) -> #("", answer)
} }
#(
Model(
..model,
state: PickQuestion,
answers: list.key_set(model.answers, question, new_value),
),
effect.none(),
)
} }
ReceiveName(_) -> #(Model(..model, state: msg), effect.none()) // Invalid states and "I want to start over" states|
GiveAnswer(_, None)
| AcceptPlayer(None)
| ReceiveName(None)
| PickedQuestion(None)
| PickQuestion -> #(
Model(Initial, model.players, None, model.answers, model.handler),
effect.none(),
)
} }
} }
@ -139,9 +149,10 @@ fn view(model: Model) -> Element(Msg) {
html.span([class("ml-8")], [ html.span([class("ml-8")], [
case model.state { case model.state {
Initial -> html.text("STATUS: Please select player") Initial -> html.text("STATUS: Please select player")
ReceiveName(_) -> html.text("STATUS: Please validate your name") ReceiveName(_) -> html.text("STATUS: Please enter your name")
PickQuestion -> html.text("STATUS: Pick question to answer") PickQuestion -> html.text("STATUS: Pick question to answer")
GiveAnswer(_, _) -> html.text("STATUS: Give your answer") GiveAnswer(_, _) -> html.text("STATUS: Give your answer")
AcceptPlayer(_) -> html.text("STATUS: Validate player")
_ -> html.text("STATUS: Waiting for next question") _ -> html.text("STATUS: Waiting for next question")
}, },
]), ]),
@ -152,11 +163,29 @@ fn view(model: Model) -> Element(Msg) {
html.text("[ACTIVE TRANSMISSIONS]"), html.text("[ACTIVE TRANSMISSIONS]"),
]), ]),
]), ]),
case model.state {
Initial -> view_players(model.players) html.div([class("participants-grid")], [
PickQuestion -> view_questions(model.answers) case model.state {
_ -> content_cell(#(10, #("Answer", "Answer question"))) Initial ->
}, case model.players {
[] -> input_new_player()
_ -> view_players(model.players)
}
PickQuestion -> view_questions(model.answers)
ReceiveName(_) -> input_new_player()
AcceptPlayer(Some(player)) -> {
let #(_, player_name) = player
shared.confirm_cell(
Some("Join as this player: " <> player_name <> "?"),
player,
AcceptPlayer,
)
}
GiveAnswer(answer, None) -> input_new_answer(answer)
_ -> content_cell(#(10, #("Answer", "Answer question")))
},
]),
]) ])
} }
@ -166,18 +195,41 @@ fn view_players(players: List(#(String, String))) {
[], [],
list.append( list.append(
list.index_map(players, fn(item, index) { list.index_map(players, fn(item, index) {
click_cell(Some(int.to_string(index)), item, PickedName) click_cell_pair(Some(int.to_string(index)), Some(item), PickedPlayer)
}), }),
[click_cell(Some("NEW"), #("new", "New Player!"), PickedName)], [click_cell_pair(Some("ENTER NEW PLAYER"), None, PickedPlayer)],
), ),
), ),
]) ])
} }
fn view_questions(answers: List(#(Int, #(String, String)))) { fn input_new_player() {
html.div([class("participant-box")], [
input_cell("Enter player name:", ReceiveName),
])
}
fn input_new_answer(question: #(String, String)) {
let #(question_id, question_text) = question
html.div([class("participant-box")], [
input_cell("Answer [" <> question_id <> "] " <> question_text, GiveAnswer(
question,
_,
)),
])
}
fn view_questions(answers: List(#(String, #(String, String)))) {
html.div( html.div(
[attribute.class("singles-grid")], [class("singles-grid")],
list.map(answers, fn(content) { content_cell(content) }), list.map(answers, fn(content) {
let #(number, #(question, answer)) = content
click_cell_pair(
Some(number <> " " <> answer),
Some(#(number, question)),
PickedQuestion,
)
}),
) )
} }
@ -197,3 +249,42 @@ fn content_cell(answer: #(Int, #(String, String))) -> Element(Msg) {
], ],
) )
} }
fn input_cell(
text: String,
on_submit handle_keydown: fn(Option(String)) -> msg,
) -> Element(msg) {
html.div([attribute.class("singles-grid")], [
html.div([], [html.text(text)]),
keyed.div([], [
#("inputheader", html.text("$>")),
#(
"input",
html.input([
attribute.type_("text"),
key_down(
fn(a: String) { decode.success(handle_keydown(Some(a))) },
fn() { decode.failure(handle_keydown(None), "") },
),
attribute.autofocus(True),
]),
),
]),
])
}
fn key_down(
success: fn(String) -> decode.Decoder(msg),
fail: fn() -> decode.Decoder(msg),
) {
event.on("keydown", {
use key <- decode.field("key", decode.string)
use value <- decode.subfield(["target", "value"], decode.string)
case key {
"Enter" if value != "" -> success(value)
_ -> fail()
}
})
|> server_component.include(["key", "target.value"])
}

View file

@ -102,6 +102,7 @@ pub fn step_prompt(text: String, fetch: fn() -> Element(a)) {
]), ]),
]) ])
} }
//fn xiew_player_list(items: List(String)) -> Element(Msg) { //fn xiew_player_list(items: List(String)) -> Element(Msg) {
// layout("Select or enter your player", None, case items { // layout("Select or enter your player", None, case items {
// [] -> [html.text("No items in your list yet.")] // [] -> [html.text("No items in your list yet.")]
@ -115,3 +116,22 @@ pub fn step_prompt(text: String, fetch: fn() -> Element(a)) {
// } // }
// }) // })
// //
pub fn confirm_cell(
title: Option(String),
accepted: #(String, String),
on_submit handle_button: fn(Option(#(String, String))) -> msg,
) -> Element(msg) {
html.div([attribute.class("participant-login")], [
html.div([attribute.class("participant-name")], [
case title {
Some(title) -> html.div([], [html.text(title)])
None -> element.none()
},
html.button([event.on_click(handle_button(Some(accepted)))], [
html.text(" <Yes> "),
]),
html.text(" - "),
html.button([event.on_click(handle_button(None))], [html.text(" <No> ")]),
]),
])
}

View file

@ -4,9 +4,7 @@ import gleam/dynamic/decode
import gleam/erlang/process.{type Subject} import gleam/erlang/process.{type Subject}
import gleam/http import gleam/http
import gleam/int import gleam/int
import gleam/json
import gleam/list import gleam/list
import gleam/option
import gleam/otp/actor.{type Started} import gleam/otp/actor.{type Started}
import shared/message.{type RoomControl, type StateControl} import shared/message.{type RoomControl, type StateControl}
import web/handlers/serve.{html_404} import web/handlers/serve.{html_404}
@ -21,7 +19,6 @@ pub fn handle_request(
case wisp.path_segments(req) { case wisp.path_segments(req) {
[] | ["index.html"] -> serve.main_html(fetch_rooms(room_handler)) [] | ["index.html"] -> serve.main_html(fetch_rooms(room_handler))
["api", "room"] -> handle_room(room_handler, req) ["api", "room"] -> handle_room(room_handler, req)
["api", "room", id, ..path] -> handle_players(room_handler, req, id, path)
["api", ..path] -> handle_admin_api(state_handler, req, path) ["api", ..path] -> handle_admin_api(state_handler, req, path)
_ -> html_404() _ -> html_404()
} }
@ -37,29 +34,6 @@ fn handle_room(room_handler: Started(Subject(RoomControl)), req: Request) {
|> serve.create_json_response |> serve.create_json_response
} }
fn handle_players(
room_handler: Started(Subject(RoomControl)),
req: Request,
id: String,
path: List(String),
) {
use json <- wisp.require_json(req)
case actor.call(room_handler.data, 1000, message.FetchRoom(id, _)) {
option.Some(#(_, player_handler)) ->
case req.method, path {
http.Post, ["player"] -> add_player(player_handler, json)
_, _ -> #(404, "bad api path", "Resource not found")
}
option.None -> #(
404,
"Room not found, or key invalid",
"resource not found",
)
}
|> serve.create_json_response
}
fn handle_admin_api( fn handle_admin_api(
actor: Started(Subject(StateControl)), actor: Started(Subject(StateControl)),
req: Request, req: Request,
@ -117,37 +91,6 @@ fn decode_info(
} }
} }
fn add_player(
player_handler: Started(Subject(message.NotifyServer)),
json_string: decode.Dynamic,
) {
let decode_player = {
use name <- decode.field("name", decode.string)
decode.success(name)
}
case decode.run(json_string, decode_player) {
Ok(player) -> {
case actor.call(player_handler.data, 1000, message.AddPlayer(player, _)) {
Ok(id) -> #(
200,
"Added player with name [" <> player <> "] given id [" <> id <> "]",
json.to_string(json.object([#("id", json.string(id))])),
)
Error(msg) -> #(
400,
"Unable to add player [" <> msg <> "]",
"player not added",
)
}
}
Error(_msg) -> #(
400,
"Could not parse player [decoding error]",
"bad request",
)
}
}
fn add_room(room_handler: Started(Subject(RoomControl)), json) { fn add_room(room_handler: Started(Subject(RoomControl)), json) {
let decode_room = { let decode_room = {
use id <- decode.field("id", decode.string) use id <- decode.field("id", decode.string)

View file

@ -1,4 +1,3 @@
import gleam/int
import gleam/option.{type Option, None, Some} import gleam/option.{type Option, None, Some}
import lustre/attribute.{class} import lustre/attribute.{class}
import lustre/element.{type Element} import lustre/element.{type Element}
@ -7,10 +6,10 @@ import lustre/event
pub fn click_cell( pub fn click_cell(
tag: Option(String), tag: Option(String),
id_value_pair: #(String, String), id: Option(String),
on_click: fn(String) -> msg, value: String,
on_click: fn(Option(String)) -> msg,
) -> Element(msg) { ) -> Element(msg) {
let #(id, value) = id_value_pair
html.div([class("participant-login"), event.on_click(on_click(id))], [ html.div([class("participant-login"), event.on_click(on_click(id))], [
html.div([class("participant-name")], [ html.div([class("participant-name")], [
html.text( html.text(
@ -24,3 +23,31 @@ pub fn click_cell(
]), ]),
]) ])
} }
pub fn click_cell_pair(
tag: Option(String),
pair: Option(#(String, String)),
on_click: fn(Option(#(String, String))) -> msg,
) -> Element(msg) {
let value = case pair {
Some(pair) -> {
let #(_, value) = pair
value
}
None -> ""
}
html.div([class("participant-login"), event.on_click(on_click(pair))], [
html.div([class("participant-name")], [
html.div([], [
html.text(
""
<> case tag {
Some(text) -> "[#" <> text <> "] "
None -> ""
},
),
]),
html.text(value),
]),
])
}