From: Geoffrey Allott Date: Sat, 27 Feb 2021 12:43:00 +0000 (+0000) Subject: refactor site code into modules for each game type X-Git-Url: https://git.pointlesshacks.com/?a=commitdiff_plain;h=aa04b852575fac6c07cf5030ad70a7518b65a195;p=pokerwave.git refactor site code into modules for each game type --- diff --git a/site/modules/chatroom.js b/site/modules/chatroom.js new file mode 100644 index 0000000..9a80c6c --- /dev/null +++ b/site/modules/chatroom.js @@ -0,0 +1,107 @@ +export class Chatroom { + constructor(container, summary, actions, username, send) { + this.container = container; + this.summary = summary; + this.actions = actions; + this.username = username; + this.send = send; + this.seats = new Map(); + + this.chatroom = document.createElement("div"); + this.chatroom.classList.add("chatroom"); + this.chat = document.createElement("div"); + this.chat.classList.add("chatroom-chat"); + this.chatroom.append(this.chat); + this.chatroom_input = document.createElement("input"); + this.chatroom_input.onchange = () => { + this.send({type: "TakeAction", action: {action: "Message", message: this.chatroom_input.value}}); + this.chatroom_input.value = ""; + }; + this.chatroom.append(this.chatroom_input); + const close = document.createElement("button"); + close.classList.add("chatroom-close"); + close.innerText = "✗"; + close.onclick = () => this.send({type: "TakeAction", action: {action: "Leave"}}); + this.chatroom.append(close); + this.container.append(this.chatroom); + + for (const user_action of this.actions) { + this.take_action(user_action, true); + } + + if (!this.seats.has(this.username)) { + this.send({type: "TakeAction", action: {action: "Join", seat: 0, chips: 0}}); + } + } + + take_action(user_action, initialising) { + const is_at_end = this.chat.scrollTop + this.chat.clientHeight == this.chat.scrollHeight; + switch (user_action.action.action) { + case "Join": + this.seats.set(user_action.username, user_action.action.seat); + this.chat.append(this.join_element(user_action.username)); + break; + case "Message": + this.chat.append(this.message_element(user_action.username, user_action.action.message)); + break; + case "Leave": + this.seats.delete(user_action.username); + this.chat.append(this.leave_element(user_action.username)); + if (!initialising && user_action.username === this.username) { + this.send({type: "JoinLobby", filter: ""}); + } + break; + default: + console.error("Unknown action for chatroom", user_action); + break; + } + if (is_at_end) { + this.chat.scrollTo({ + top: this.chat.scrollHeight - this.chat.clientHeight, + behavior: "smooth" + }); + } + } + + join_element(username) { + const join_element = document.createElement("div"); + join_element.classList.add("chatroom-join"); + const username_element = document.createElement("div"); + username_element.classList.add("chatroom-username"); + username_element.innerText = username; + join_element.append(username_element); + const text_element = document.createElement("div"); + text_element.classList.add("chatroom-text"); + text_element.innerText = "Joined"; + join_element.append(text_element); + return join_element; + } + + message_element(username, message) { + const message_element = document.createElement("div"); + message_element.classList.add("chatroom-message"); + const username_element = document.createElement("div"); + username_element.classList.add("chatroom-username"); + username_element.innerText = username; + message_element.append(username_element); + const text_element = document.createElement("div"); + text_element.classList.add("chatroom-text"); + text_element.innerText = message; + message_element.append(text_element); + return message_element; + } + + leave_element(username) { + const leave_element = document.createElement("div"); + leave_element.classList.add("chatroom-leave"); + const username_element = document.createElement("div"); + username_element.classList.add("chatroom-username"); + username_element.innerText = username; + leave_element.append(username_element); + const text_element = document.createElement("div"); + text_element.classList.add("chatroom-text"); + text_element.innerText = "Left"; + leave_element.append(text_element); + return leave_element; + } +} diff --git a/site/modules/gamelist.js b/site/modules/gamelist.js new file mode 100644 index 0000000..5207190 --- /dev/null +++ b/site/modules/gamelist.js @@ -0,0 +1,89 @@ +export class GameList { + constructor(container, games, send) { + this.container = container; + this.games = games; + this.send = send; + + const game_list_outside = document.createElement("div"); + game_list_outside.classList.add("game-list-outside"); + this.game_list_container = document.createElement("div"); + this.game_list_container.classList.add("game-list-container"); + this.game_list = document.createElement("div"); + this.game_list.classList.add("game-list"); + this.game_list_container.append(this.game_list); + game_list_outside.append(this.game_list_container); + this.game_list_filter_input = document.createElement("input"); + game_list_outside.append(this.game_list_filter_input); + const game_list_options = document.createElement("button"); + const game_list_option_menu = document.createElement("div"); + game_list_option_menu.classList.add("game-list-option-menu"); + game_list_option_menu.classList.add("hidden"); + const game_list_option_logout = document.createElement("div"); + game_list_option_logout.innerText = "Logout"; + game_list_option_logout.onclick = () => this.send({type: "Logout"}); + game_list_option_menu.append(game_list_option_logout); + game_list_options.append(game_list_option_menu); + game_list_options.onclick = () => game_list_option_menu.classList.toggle("hidden"); + game_list_outside.append(game_list_options); + this.container.append(game_list_outside); + + for (const game of this.games) { + const game_element = this.game_element(game); + this.game_list.append(game_element); + } + this.game_list_container.scrollTo({ + top: this.game_list_container.scrollHeight - this.game_list_container.clientHeight, + behavior: "auto" + }); + } + + new_game(game) { + const is_at_end = this.game_list_container.scrollTop + this.game_list_container.clientHeight == this.game_list_container.scrollHeight; + this.games.push(game); + const game_element = this.game_element(game); + this.game_list.append(game_element); + if (is_at_end) { + this.game_list_container.scrollTo({ + top: this.game_list_container.scrollHeight - this.game_list_container.clientHeight, + behavior: "smooth" + }); + } + } + + game_element(game) { + console.log("getting game element for ", game); + + const game_element = document.createElement("div"); + game_element.classList.add("game-summary"); + + const id = document.createElement("div"); + id.innerText = "#" + game.id; + id.classList.add("game-id"); + game_element.append(id); + + const title = document.createElement("div"); + title.innerText = game.settings.title; + title.classList.add("game-title"); + game_element.append(title); + + const format = document.createElement("div"); + format.innerText = game.settings.format; + format.classList.add("game-format"); + game_element.append(format); + + const settings = document.createElement("ul"); + settings.classList.add("game-settings"); + for (const setting of Object.keys(game.settings)) { + if (setting !== "id" && setting !== "title" && setting !== "format") { + const li = document.createElement("li"); + li.innerText = setting + ": " + game.settings[setting]; + settings.append(li); + } + } + game_element.append(settings); + + game_element.onclick = () => this.send({type: "JoinGame", id: game.id}); + + return game_element; + } +} diff --git a/site/modules/socket.js b/site/modules/socket.js index b6f098e..27e2ba9 100644 --- a/site/modules/socket.js +++ b/site/modules/socket.js @@ -1,6 +1,8 @@ const svgns = "http://www.w3.org/2000/svg"; -import { card_href, suit_href } from "./card.js"; +import { GameList } from "./gamelist.js"; +import { Chatroom } from "./chatroom.js"; +import { KnockOutWhist } from "./whist.js"; export class Socket { constructor(container, login_all_sockets) { @@ -58,12 +60,11 @@ export class Socket { this.state = "LoggedIn"; break; case "JoinLobbySuccess": - this.games = message.games; - this.create_game_list(); + this.game = new GameList(this.container, message.games, message => this.send(message)) this.state = "InLobby"; break; case "NewGame": - this.new_game(message.game); + this.game.new_game(message.game); break; case "JoinGameFailure": console.error("Joining game failed: " + message.reason); @@ -71,23 +72,19 @@ export class Socket { break; case "JoinGameSuccess": this.game = {summary: message.summary, actions: message.actions}; - this.create_game_display(); + this.create_game_display(message.summary, message.actions); this.state = "InGame"; break; - case "TakeActionSuccess": - this.add_action({username: this.auth.username, action: this.action}); - delete this.action; - break; case "TakeActionFailure": - if (message.reason === "SeatNotAvailable") { - this.take_action({action: "Join", seat: this.action.seat + 1, chips: this.action.chips}); + if (message.reason === "Seat not available") { + this.send({type: "TakeAction", action: {action: "Join", seat: message.action.action.seat + 1, chips: message.action.action.chips}}); } else { - console.error("Taking action failed: " + message.reason, this.action); - delete this.action; + console.error("Taking action failed: " + message.reason, message.action); } break; + case "TakeActionSuccess": case "NewAction": - this.add_action(message.action); + this.game.take_action(message.action); break; case "LogoutSuccess": delete this.auth; @@ -114,436 +111,17 @@ export class Socket { document.getElementById("password-input").onchange = this.login_all_sockets; } - game_element(game) { - console.log("getting game element for ", game); - - const game_element = document.createElement("div"); - game_element.classList.add("game-summary"); - - const id = document.createElement("div"); - id.innerText = "#" + game.id; - id.classList.add("game-id"); - game_element.append(id); - - const title = document.createElement("div"); - title.innerText = game.settings.title; - title.classList.add("game-title"); - game_element.append(title); - - const format = document.createElement("div"); - format.innerText = game.settings.format; - format.classList.add("game-format"); - game_element.append(format); - - const settings = document.createElement("ul"); - settings.classList.add("game-settings"); - for (const setting of Object.keys(game.settings)) { - if (setting !== "id" && setting !== "title" && setting !== "format") { - const li = document.createElement("li"); - li.innerText = setting + ": " + game.settings[setting]; - settings.append(li); - } - } - game_element.append(settings); - - game_element.onclick = () => this.join_game(game); - - return game_element; - } - - new_game(game) { - const is_at_end = this.game_list_container.scrollTop + this.game_list_container.clientHeight == this.game_list_container.scrollHeight; - this.games.push(game); - const game_element = this.game_element(game); - this.game_list.append(game_element); - if (is_at_end) { - this.game_list_container.scrollTo({ - top: this.game_list_container.scrollHeight - this.game_list_container.clientHeight, - behavior: "smooth" - }); - } - } - - join_game(game) { - this.send({type: "JoinGame", id: game.id}); - this.state = "JoinGameSent"; - } - - create_game_list() { - this.container.textContent = ""; - const game_list_outside = document.createElement("div"); - game_list_outside.classList.add("game-list-outside"); - this.game_list_container = document.createElement("div"); - this.game_list_container.classList.add("game-list-container"); - this.game_list = document.createElement("div"); - this.game_list.classList.add("game-list"); - this.game_list_container.append(this.game_list); - game_list_outside.append(this.game_list_container); - this.game_list_filter_input = document.createElement("input"); - game_list_outside.append(this.game_list_filter_input); - const game_list_options = document.createElement("button"); - const game_list_option_menu = document.createElement("div"); - game_list_option_menu.classList.add("game-list-option-menu"); - game_list_option_menu.classList.add("hidden"); - const game_list_option_logout = document.createElement("div"); - game_list_option_logout.innerText = "Logout"; - game_list_option_logout.onclick = () => this.logout(); - game_list_option_menu.append(game_list_option_logout); - game_list_options.append(game_list_option_menu); - game_list_options.onclick = () => game_list_option_menu.classList.toggle("hidden"); - game_list_outside.append(game_list_options); - this.container.append(game_list_outside); - this.redraw_games(); - } - - redraw_games() { - this.game_list.textContent = ""; - for (const game of this.games) { - const game_element = this.game_element(game); - this.game_list.append(game_element); - } - this.game_list_container.scrollTo({ - top: this.game_list_container.scrollHeight - this.game_list_container.clientHeight, - behavior: "auto" - }); - } - - take_action(action) { - this.action = action; - this.send({type: "TakeAction", action: action}); - } - - create_chatroom() { - this.container.textContent = ""; - this.chatroom = document.createElement("div"); - this.chatroom.classList.add("chatroom"); - this.chatroom_chat = document.createElement("div"); - this.chatroom_chat.classList.add("chatroom-chat"); - this.chatroom.append(this.chatroom_chat); - this.chatroom_input = document.createElement("input"); - this.chatroom_input.onchange = () => { - this.take_action({action: "Message", message: this.chatroom_input.value}); - this.chatroom_input.value = ""; - }; - this.chatroom.append(this.chatroom_input); - const chatroom_close = document.createElement("button"); - chatroom_close.classList.add("chatroom-close"); - chatroom_close.innerText = "✗"; - chatroom_close.onclick = () => this.take_action({action: "Leave"}); - this.chatroom.append(chatroom_close); - this.container.append(this.chatroom); - this.redraw_chatroom(); - } - - create_knock_out_whist() { + create_game_display(summary, actions) { this.container.textContent = ""; - - this.svg = document.createElementNS(svgns, "svg"); - this.svg.classList.add("knock-out-whist"); - this.svg.setAttribute("viewBox", "0 0 500 500"); - - const background = document.createElementNS(svgns, "rect"); - background.setAttribute("width", "500"); - background.setAttribute("height", "500"); - background.setAttribute("fill", "#404040"); - this.svg.append(background); - - const table = document.createElementNS(svgns, "ellipse"); - table.setAttribute("cx", "250"); - table.setAttribute("cy", "253"); - table.setAttribute("rx", "250"); - table.setAttribute("ry", "110"); - table.setAttribute("fill", "#604010"); - this.svg.append(table); - - const felt = document.createElementNS(svgns, "ellipse"); - felt.setAttribute("cx", "250"); - felt.setAttribute("cy", "250"); - felt.setAttribute("rx", "240"); - felt.setAttribute("ry", "100"); - felt.setAttribute("fill", "green"); - this.svg.append(felt); - - const suits = document.createElementNS(svgns, "rect"); - suits.setAttribute("x", "210"); - suits.setAttribute("y", "400"); - suits.setAttribute("width", "100"); - suits.setAttribute("height", "30"); - suits.setAttribute("fill", "#00000000"); - this.svg.append(suits); - - this.glyphs = new Map(); - let x = 210; - for (const suit of ["Clubs", "Diamonds", "Hearts", "Spades"]) { - const highlight = document.createElementNS(svgns, "circle"); - highlight.setAttribute("cx", x + 12.5); - highlight.setAttribute("cy", "415"); - highlight.setAttribute("r", "10"); - highlight.classList.add("suit-highlight"); - highlight.onclick = () => this.take_action({action: "ChooseTrumps", suit: suit}); - this.svg.append(highlight); - this.glyphs.set(suit, highlight); - const glyph = document.createElementNS(svgns, "image"); - glyph.setAttribute("x", x); - glyph.setAttribute("y", "400"); - glyph.setAttribute("width", "25"); - glyph.setAttribute("height", "30"); - glyph.setAttribute("href", suit_href(suit)); - glyph.classList.add("suit-glyph"); - this.svg.append(glyph); - x += 25; - } - - this.container.append(this.svg); - this.redraw_knock_out_whist(); - } - - join_element(username) { - const join_element = document.createElement("div"); - join_element.classList.add("chatroom-join"); - const username_element = document.createElement("div"); - username_element.classList.add("chatroom-username"); - username_element.innerText = username; - join_element.append(username_element); - const text_element = document.createElement("div"); - text_element.classList.add("chatroom-text"); - text_element.innerText = "Joined"; - join_element.append(text_element); - return join_element; - } - - message_element(username, message) { - const message_element = document.createElement("div"); - message_element.classList.add("chatroom-message"); - const username_element = document.createElement("div"); - username_element.classList.add("chatroom-username"); - username_element.innerText = username; - message_element.append(username_element); - const text_element = document.createElement("div"); - text_element.classList.add("chatroom-text"); - text_element.innerText = message; - message_element.append(text_element); - return message_element; - } - - leave_element(username) { - const leave_element = document.createElement("div"); - leave_element.classList.add("chatroom-leave"); - const username_element = document.createElement("div"); - username_element.classList.add("chatroom-username"); - username_element.innerText = username; - leave_element.append(username_element); - const text_element = document.createElement("div"); - text_element.classList.add("chatroom-text"); - text_element.innerText = "Left"; - leave_element.append(text_element); - return leave_element; - } - - set_card_positions() { - const seats = Math.max(...this.game.seats.values()) + 1; - const my_seat = this.game.seats.get(this.auth.username); - for (const [username, cards] of this.game.hands) { - const seat = this.game.seats.get(username); - const angle = ((seat - my_seat) % seats) * 2 * Math.PI / seats; - const offset = cards.length * 10; - let x = 227.5 + offset + 240 * Math.sin(angle); - const y = 210 + 120 * Math.cos(angle); - for (const {card, image} of cards) { - image.classList.toggle("my-card", username === this.auth.username); - image.setAttribute("x", x); - image.setAttribute("y", y); - x -= 20; - } - } - for (const {card, image} of this.game.trick) { - const x = 227.5; - const y = 210; - image.classList.remove("my-card"); - image.setAttribute("x", x); - image.setAttribute("y", y); - } - for (const {card, image} of this.game.community) { - const x = 120.0; - const y = 210; - image.setAttribute("x", x); - image.setAttribute("y", y); - } - } - - card_image(username, card) { - const image = document.createElementNS(svgns, "image"); - image.setAttribute("width", "45"); - image.setAttribute("height", "70"); - image.setAttribute("href", card_href(card)); - if (username === this.auth.username) { - image.onclick = () => this.take_action({action: "PlayCard", card: card}); - } - this.svg.append(image); - return image; - } - - set_trumps(suit) { - this.game.trump_suit = suit; - for (const [suit, glyph] of this.glyphs) { - glyph.classList.toggle("trumps", suit === this.game.trump_suit); - } - } - - add_action(user_action, initialising) { switch (this.game.summary.settings.format) { case "Chatroom": - const is_at_end = this.chatroom_chat.scrollTop + this.chatroom_chat.clientHeight == this.chatroom_chat.scrollHeight; - switch (user_action.action.action) { - case "Join": - this.game.seats.set(user_action.username, user_action.action.seat); - this.chatroom_chat.append(this.join_element(user_action.username)); - break; - case "Message": - this.chatroom_chat.append(this.message_element(user_action.username, user_action.action.message)); - break; - case "Leave": - this.game.seats.delete(user_action.username); - this.chatroom_chat.append(this.leave_element(user_action.username)); - if (!initialising && user_action.username === this.auth.username) { - this.send({type: "JoinLobby", filter: ""}); - } - break; - default: - console.error("Unknown action for chatroom", user_action); - break; - } - if (is_at_end) { - this.chatroom_chat.scrollTo({ - top: this.chatroom_chat.scrollHeight - this.chatroom_chat.clientHeight, - behavior: "smooth" - }); - } + this.game = new Chatroom(this.container, summary, actions, this.auth.username, message => this.send(message)); break; case "KnockOutWhist": - switch (user_action.action.action) { - case "Join": - this.game.seats.set(user_action.username, user_action.action.seat); - this.game.hands.set(user_action.username, []); - break; - case "Leave": - case "KnockedOut": - this.game.seats.delete(user_action.username); - this.game.hands.delete(user_action.username); - break; - case "NextToDeal": - this.dealer = user_action.username; - for (const card of this.game.community) { - this.svg.removeChild(card.image); - } - this.game.community = []; - for (const [username, hand] of this.game.hands) { - for (const card of hand) { - this.svg.removeChild(card.image); - } - } - this.game.hands.clear(); - this.set_trumps(null); - break; - case "ReceiveCard": - case "RevealCard": - const card = { - card: user_action.action.card, - image: this.card_image(user_action.username, user_action.action.card), - }; - if (!this.game.hands.has(user_action.username)) { - this.game.hands.set(user_action.username, []); - } - this.game.hands.get(user_action.username).push(card); - this.set_card_positions(); - break; - case "PlayCard": - let removed = false; - const cards = this.game.hands.get(user_action.username).filter((card) => { - if (card.card === null) { - if (!removed) { - this.svg.removeChild(card.image); - this.game.trick.push({card: user_action.action.card, image: this.card_image(user_action.username, user_action.action.card)}); - removed = true; - return false; - } else { - return true; - } - } else if (card.card.suit === user_action.action.card.suit && card.card.rank === user_action.action.card.rank) { - this.game.trick.push(card); - return false; - } else { - return true; - } - }); - this.game.hands.set(user_action.username, cards); - this.set_card_positions(); - break; - case "ChooseTrumps": - this.set_trumps(user_action.action.suit); - break; - case "CommunityCard": - const community_card = { - card: user_action.action.card, - image: this.card_image(null, user_action.action.card), - }; - this.game.community.push(community_card); - this.set_trumps(user_action.action.card.suit); - this.set_card_positions(); - break; - case "WinTrick": - for (const card of this.game.trick) { - this.svg.removeChild(card.image); - } - this.game.trick = []; - break; - case "EndDeal": - console.log(this); - break; - case "WinGame": - console.log(this); - this.set_trumps(null); - break; - default: - console.error("Unhandled action for knock-out whist", user_action); - break; - } - break; - } - } - - redraw_chatroom() { - this.chatroom_chat.textContent = ""; - for (const user_action of this.game.actions) { - this.add_action(user_action, true); - } - } - - redraw_knock_out_whist() { - for (const user_action of this.game.actions) { - this.add_action(user_action, true); - } - } - - create_game_display() { - this.container.textContent = ""; - switch (this.game.summary.settings.format) { - case "Chatroom": - this.game.seats = new Map(); - this.create_chatroom(); - if (!this.game.seats.has(this.auth.username)) { - this.take_action({action: "Join", seat: 0, chips: 0}); - } + this.game = new KnockOutWhist(this.container, summary, actions, this.auth.username, message => this.send(message)); break; - case "KnockOutWhist": - this.game.seats = new Map(); - this.game.hands = new Map(); - this.game.community = []; - this.game.trick = []; - this.create_knock_out_whist(); - if (!this.game.seats.has(this.auth.username)) { - this.take_action({action: "Join", seat: 0, chips: 0}); - } + default: + console.error("Unknown format: ", this.game.summary.settings.format); break; } } diff --git a/site/modules/whist.js b/site/modules/whist.js new file mode 100644 index 0000000..d0b63de --- /dev/null +++ b/site/modules/whist.js @@ -0,0 +1,309 @@ +const svgns = "http://www.w3.org/2000/svg"; + +import { card_href, suit_href } from "./card.js"; + +export class KnockOutWhist { + constructor(container, summary, actions, username, send) { + this.container = container; + this.summary = summary; + this.actions = actions; + this.username = username; + this.send = send; + this.seats = new Map(); + this.hands = new Map(); + this.community = []; + this.trick = []; + this.user_icons = new Map(); + + this.svg = document.createElementNS(svgns, "svg"); + this.svg.classList.add("knock-out-whist"); + this.svg.setAttribute("viewBox", "0 0 500 500"); + + const background = document.createElementNS(svgns, "rect"); + background.setAttribute("width", "500"); + background.setAttribute("height", "500"); + background.setAttribute("fill", "#404040"); + this.svg.append(background); + + const table = document.createElementNS(svgns, "ellipse"); + table.setAttribute("cx", "250"); + table.setAttribute("cy", "253"); + table.setAttribute("rx", "250"); + table.setAttribute("ry", "110"); + table.setAttribute("fill", "#604010"); + this.svg.append(table); + + const felt = document.createElementNS(svgns, "ellipse"); + felt.setAttribute("cx", "250"); + felt.setAttribute("cy", "250"); + felt.setAttribute("rx", "240"); + felt.setAttribute("ry", "100"); + felt.setAttribute("fill", "green"); + this.svg.append(felt); + + const suits = document.createElementNS(svgns, "rect"); + suits.setAttribute("x", "340"); + suits.setAttribute("y", "235"); + suits.setAttribute("width", "100"); + suits.setAttribute("height", "30"); + suits.setAttribute("rx", "10"); + suits.setAttribute("fill", "darkgreen"); + this.svg.append(suits); + + this.glyphs = new Map(); + let x = 340; + for (const suit of ["Clubs", "Diamonds", "Hearts", "Spades"]) { + const highlight = document.createElementNS(svgns, "circle"); + highlight.setAttribute("cx", x + 12.5); + highlight.setAttribute("cy", "250"); + highlight.setAttribute("r", "10"); + highlight.classList.add("suit-highlight"); + highlight.onclick = () => this.send({type: "TakeAction", action: {action: "ChooseTrumps", suit: suit}}); + this.svg.append(highlight); + this.glyphs.set(suit, highlight); + const glyph = document.createElementNS(svgns, "image"); + glyph.setAttribute("x", x); + glyph.setAttribute("y", "235"); + glyph.setAttribute("width", "25"); + glyph.setAttribute("height", "30"); + glyph.setAttribute("href", suit_href(suit)); + glyph.classList.add("suit-glyph"); + this.svg.append(glyph); + x += 25; + } + + this.container.append(this.svg); + for (const user_action of this.actions) { + this.take_action(user_action); + } + + if (!this.seats.has(this.username)) { + this.send({type: "TakeAction", action: {action: "Join", seat: 0, chips: 0}}); + } + } + + redraw_players() { + const active_player = this.call || this.active; + for (const [username, [icon, active]] of this.user_icons) { + if (!this.seats.has(username)) { + this.svg.removeChild(icon); + this.svg.removeChild(active); + this.user_icons.delete(username); + } else { + const seat = this.seats.get(username); + const angle = ((seat - this.my_seat) % this.num_seats) * 2 * Math.PI / this.num_seats; + const x = 240 - 180 * Math.sin(angle); + const y = 250 + 120 * Math.cos(angle) + 50 * Math.sign(Math.cos(angle)); + icon.setAttribute("x", x); + icon.setAttribute("y", y); + active.classList.toggle("active", active_player === username); + active.setAttribute("cx", x - 10); + active.setAttribute("cy", y - 5); + } + } + for (const [username, seat] of this.seats) { + if (!this.user_icons.has(username)) { + const icon = document.createElementNS(svgns, "text"); + const text = document.createTextNode(username); + icon.append(text); + const angle = ((seat - this.my_seat) % this.num_seats) * 2 * Math.PI / this.num_seats; + const x = 240 - 180 * Math.sin(angle); + const y = 250 + 120 * Math.cos(angle) + 50 * (Math.sign(Math.cos(angle)) || 1); + icon.setAttribute("x", x); + icon.setAttribute("y", y); + this.svg.append(icon); + const active = document.createElementNS(svgns, "circle"); + active.setAttribute("cx", x - 10); + active.setAttribute("cy", y - 5); + active.setAttribute("r", 5); + active.classList.add("active-indicator"); + active.classList.toggle("active", active_player === username); + this.svg.append(active); + this.user_icons.set(username, [icon, active]); + } + } + } + + redraw_cards() { + for (const [username, cards] of this.hands) { + const seat = this.seats.get(username); + const angle = ((seat - this.my_seat) % this.num_seats) * 2 * Math.PI / this.num_seats; + const offset = cards.length * 10; + let x = 227.5 + offset - 180 * Math.sin(angle); + const y = 210 + 120 * Math.cos(angle); + for (const {card, image} of cards) { + image.classList.toggle("my-card", username === this.username); + image.setAttribute("x", x); + image.setAttribute("y", y); + x -= 20; + } + } + for (const {card, image} of this.trick) { + const x = 227.5; + const y = 210; + image.classList.remove("my-card"); + image.setAttribute("x", x); + image.setAttribute("y", y); + } + for (const {card, image} of this.community) { + const x = 120.0; + const y = 210; + image.setAttribute("x", x); + image.setAttribute("y", y); + } + } + + redraw_trumps() { + for (const [suit, glyph] of this.glyphs) { + glyph.classList.toggle("trumps", suit === this.trumps); + } + } + + card_image(username, card) { + const image = document.createElementNS(svgns, "image"); + image.setAttribute("width", "45"); + image.setAttribute("height", "70"); + image.setAttribute("href", card_href(card)); + if (username === this.username) { + image.onclick = () => this.send({type: "TakeAction", action: {action: "PlayCard", card: card}}); + } + this.svg.append(image); + return image; + } + + player_after(username) { + const in_seat = this.seats.get(username); + let [player_after, player_after_seat] = [username, null]; + for (const [username, seat] of this.seats) { + if (seat > in_seat && (player_after_seat === null || seat < player_after_seat)) { + player_after = username; + player_after_seat = seat; + } + } + if (player_after_seat === null) for (const [username, seat] of this.seats) { + if (player_after_seat === null || seat < player_after_seat) { + player_after = username; + player_after_seat = seat; + } + } + return player_after; + } + + take_action(user_action) { + switch (user_action.action.action) { + case "Join": + this.seats.set(user_action.username, user_action.action.seat); + this.hands.set(user_action.username, []); + this.num_seats = Math.max(...this.seats.values()) + 1; + this.my_seat = this.seats.get(this.username) || 0; + this.redraw_players(); + break; + case "Leave": + this.seats.delete(user_action.username); + this.hands.delete(user_action.username); + this.num_seats = Math.max(...this.seats.values()) + 1; + this.my_seat = this.seats.get(this.username) || 0; + this.redraw_players(); + break; + case "KnockedOut": + this.seats.delete(user_action.username); + this.hands.delete(user_action.username); + this.redraw_players(); + break; + case "NextToDeal": + this.dealer = user_action.username; + for (const card of this.community) { + this.svg.removeChild(card.image); + } + this.community = []; + for (const [username, hand] of this.hands) { + for (const card of hand) { + this.svg.removeChild(card.image); + } + } + this.hands.clear(); + this.trumps = null; + this.active = this.player_after(user_action.username); + this.redraw_trumps(); + this.redraw_players(); + break; + case "ReceiveCard": + case "RevealCard": + const card = { + card: user_action.action.card, + image: this.card_image(user_action.username, user_action.action.card), + }; + if (!this.hands.has(user_action.username)) { + this.hands.set(user_action.username, []); + } + this.hands.get(user_action.username).push(card); + this.redraw_cards(); + break; + case "PlayCard": + let removed = false; + const cards = this.hands.get(user_action.username).filter((card) => { + if (card.card === null) { + if (!removed) { + this.svg.removeChild(card.image); + this.trick.push({card: user_action.action.card, image: this.card_image(user_action.username, user_action.action.card)}); + removed = true; + return false; + } else { + return true; + } + } else if (card.card.suit === user_action.action.card.suit && card.card.rank === user_action.action.card.rank) { + this.trick.push(card); + return false; + } else { + return true; + } + }); + this.hands.set(user_action.username, cards); + this.active = this.player_after(user_action.username); + console.log("active: ", this.active); + this.redraw_cards(); + this.redraw_players(); + break; + case "ChooseTrumps": + this.trumps = user_action.action.suit; + this.call = null; + this.redraw_trumps(); + this.redraw_players(); + break; + case "CommunityCard": + const community_card = { + card: user_action.action.card, + image: this.card_image(null, user_action.action.card), + }; + this.community.push(community_card); + this.trumps = user_action.action.card.suit; + this.redraw_trumps(); + this.redraw_cards(); + break; + case "WinTrick": + for (const card of this.trick) { + this.svg.removeChild(card.image); + } + this.trick = []; + this.active = user_action.username; + this.redraw_players(); + break; + case "WinCall": + this.call = user_action.username; + this.redraw_players(); + break; + case "EndDeal": + this.active = this.player_after(user_action.username); + this.redraw_players(); + break; + case "WinGame": + console.log(this); + this.trumps = null; + this.redraw_trumps(); + break; + default: + console.error("Unhandled action for knock-out whist", user_action); + break; + } + } +} diff --git a/site/style/whist.css b/site/style/whist.css index 97cae0b..43b41cd 100644 --- a/site/style/whist.css +++ b/site/style/whist.css @@ -32,3 +32,11 @@ svg { .suit-highlight.trumps:hover { fill: #4040ff; } + +.active-indicator { + fill: #000060; +} + +.active-indicator.active { + fill: #2020ff; +} diff --git a/src/api.rs b/src/api.rs index e90176c..27b566d 100644 --- a/src/api.rs +++ b/src/api.rs @@ -50,8 +50,8 @@ pub enum ServerMessage { JoinGameSuccess { summary: GameSummary, actions: Vec }, JoinGameFailure { reason: String }, NewAction { action: UserAction }, - TakeActionSuccess, - TakeActionFailure { reason: String }, + TakeActionSuccess { action: UserAction }, + TakeActionFailure { action: UserAction, reason: String }, NewMessage { username: Username, message: String }, LeaveGameSuccess, LeaveGameFailure { reason: String }, diff --git a/src/client.rs b/src/client.rs index 6507d69..c7b0f1c 100644 --- a/src/client.rs +++ b/src/client.rs @@ -179,27 +179,27 @@ impl ConnectionState { loop { let len = game.actions_len(); match game.validate_action(action.clone()) { - Ok(action) => match self.server.take_action(id, len, &action).await { - Ok(ActionStatus::Committed) => match game.take_action(action) { - Ok(()) => return ServerMessage::TakeActionSuccess, - Err(err) => return ServerMessage::TakeActionFailure{reason: err.to_string()}, + Ok(validated) => match self.server.take_action(id, len, &validated).await { + Ok(ActionStatus::Committed) => match game.take_action(validated.clone()) { + Ok(()) => return ServerMessage::TakeActionSuccess{action}, + Err(err) => return ServerMessage::TakeActionFailure{action, reason: err.to_string()}, } Ok(ActionStatus::Interrupted) => { debug!("Action {:?} was interrupted - updating game state", action); match self.server.game_state(id, len).await { - Ok(actions) => for action in actions { - if let Err(err) = game.take_action(action) { - return ServerMessage::TakeActionFailure{reason: err.to_string()}; + Ok(actions) => for new_action in actions { + if let Err(err) = game.take_action(new_action) { + return ServerMessage::TakeActionFailure{action, reason: err.to_string()}; } } Err(err) => { - return ServerMessage::TakeActionFailure{reason: err.to_string()}; + return ServerMessage::TakeActionFailure{action, reason: err.to_string()}; } } } - Err(err) => return ServerMessage::TakeActionFailure{reason: err.to_string()}, + Err(err) => return ServerMessage::TakeActionFailure{action, reason: err.to_string()}, } - Err(err) => return ServerMessage::TakeActionFailure{reason: err.to_string()}, + Err(err) => return ServerMessage::TakeActionFailure{action, reason: err.to_string()}, } } } diff --git a/src/game/action.rs b/src/game/action.rs index 4c8b347..ed5af55 100644 --- a/src/game/action.rs +++ b/src/game/action.rs @@ -37,6 +37,7 @@ pub enum Action { Fold, Bet { chips: u64 }, WinTrick, + WinCall, WinHand { chips: u64 }, WinGame, Message { message: String }, @@ -61,7 +62,10 @@ pub enum ActionError { SeatNotAvailable, NoSeatAvailable, OutOfTurn, + Dealing, + MustChooseTrumps, CardNotPlayable, + CardNotAvailable, InvalidActionForGameType, GameHasEnded, } @@ -69,15 +73,18 @@ pub enum ActionError { impl Display for ActionError { fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { match self { - ActionError::NotAuthorised => f.write_str("NotAuthorised"), - ActionError::AlreadyJoined => f.write_str("AlreadyJoined"), - ActionError::GameHasStarted => f.write_str("GameHasStarted"), - ActionError::SeatNotAvailable => f.write_str("SeatNotAvailable"), - ActionError::NoSeatAvailable => f.write_str("NoSeatAvailable"), - ActionError::OutOfTurn => f.write_str("OutOfTurn"), - ActionError::CardNotPlayable => f.write_str("CardNotPlayable"), - ActionError::InvalidActionForGameType => f.write_str("InvalidActionForGameType"), - ActionError::GameHasEnded => f.write_str("GameHasEnded"), + ActionError::NotAuthorised => f.write_str("Not authorised"), + ActionError::AlreadyJoined => f.write_str("User has already joined"), + ActionError::GameHasStarted => f.write_str("Game has already started"), + ActionError::SeatNotAvailable => f.write_str("Seat not available"), + ActionError::NoSeatAvailable => f.write_str("No seat available"), + ActionError::OutOfTurn => f.write_str("Out of turn"), + ActionError::Dealing => f.write_str("Please wait for deal to complete"), + ActionError::MustChooseTrumps => f.write_str("Trumps must be chosen first"), + ActionError::CardNotPlayable => f.write_str("Card is not legally playable"), + ActionError::CardNotAvailable => f.write_str("Card does not exist in player's hand"), + ActionError::InvalidActionForGameType => f.write_str("Invalid action for game type"), + ActionError::GameHasEnded => f.write_str("Game has ended"), } } } diff --git a/src/game/whist.rs b/src/game/whist.rs index 4f6490c..f4d4ecd 100644 --- a/src/game/whist.rs +++ b/src/game/whist.rs @@ -16,6 +16,7 @@ enum KnockOutWhistState { ChoosingTrumps, Playing, CutForCall, + RoundCompleted, Completed, } @@ -128,12 +129,14 @@ impl Game for KnockOutWhist { Ok(ValidatedUserAction(UserAction{username, action: Action::Join{seat, chips: 0}})) } } + (KnockOutWhistState::Completed, Action::Join{..}) => Err(ActionError::GameHasEnded), + (_, Action::Join{..}) => Err(ActionError::GameHasStarted), (_, _) if !self.seats.contains_player(username) => Err(ActionError::NotAuthorised), (KnockOutWhistState::NotStarted, Action::Leave) => { Ok(ValidatedUserAction(UserAction{username, action: Action::Leave})) } (_, Action::Leave) => Err(ActionError::GameHasStarted), - (KnockOutWhistState::Dealing, _) => Err(ActionError::OutOfTurn), + (KnockOutWhistState::Dealing, _) => Err(ActionError::Dealing), (KnockOutWhistState::ChoosingTrumps, Action::ChooseTrumps{suit}) => { if Some(username) == self.call { Ok(ValidatedUserAction(UserAction{username, action: Action::ChooseTrumps{suit}})) @@ -141,19 +144,20 @@ impl Game for KnockOutWhist { Err(ActionError::OutOfTurn) } } - (KnockOutWhistState::ChoosingTrumps, _) => Err(ActionError::OutOfTurn), + (KnockOutWhistState::ChoosingTrumps, _) => Err(ActionError::MustChooseTrumps), (KnockOutWhistState::Playing, Action::PlayCard{card}) => { if Some(username) != self.active { Err(ActionError::OutOfTurn) } else if !self.hand_contains_card(username, card) { - Err(ActionError::CardNotPlayable) + Err(ActionError::CardNotAvailable) } else if matches!(self.led, Some(led) if card.suit != led && self.hand_contains_suit(username, led)) { Err(ActionError::CardNotPlayable) } else { Ok(ValidatedUserAction(UserAction{username, action: Action::PlayCard{card}})) } } - (KnockOutWhistState::CutForCall, _) => Err(ActionError::OutOfTurn), + (KnockOutWhistState::CutForCall, _) => Err(ActionError::Dealing), + (KnockOutWhistState::RoundCompleted, _) => Err(ActionError::Dealing), (KnockOutWhistState::Completed, _) => Err(ActionError::GameHasEnded), (_, _) => Err(ActionError::InvalidActionForGameType), } @@ -242,14 +246,7 @@ impl Game for KnockOutWhist { if self.winners.len() == 1 { self.call = self.winners.iter().next().cloned(); } else { - self.receiver = self.dealer; - while let Some(receiver) = self.receiver { - if !self.winners.contains(&receiver) { - self.receiver = self.seats.player_after(receiver); - } else { - break; - } - } + self.receiver = self.dealer.and_then(|dealer| self.seats.player_after_in(dealer, &self.winners)); self.deck = FIFTY_TWO_CARD_DECK.iter().cloned().collect(); self.hands.clear(); self.trump_card = None; @@ -294,6 +291,11 @@ impl Game for KnockOutWhist { } Ok(()) } + (KnockOutWhistState::CutForCall, Action::WinCall) | (KnockOutWhistState::Playing, Action::WinCall) => { + self.call = Some(username); + self.state = KnockOutWhistState::RoundCompleted; + Ok(()) + } (KnockOutWhistState::Completed, _) => Err(ActionError::GameHasEnded), (_, _) => Err(ActionError::InvalidActionForGameType), } @@ -344,8 +346,8 @@ impl Game for KnockOutWhist { return Some(ValidatedUserAction(UserAction{username, action: Action::WinGame})); } } - if let Some(username) = self.dealer.and_then(|dealer| self.seats.player_after(dealer)) { - return Some(ValidatedUserAction(UserAction{username, action: Action::NextToDeal})); + if let Some(username) = self.call { + return Some(ValidatedUserAction(UserAction{username, action: Action::WinCall})); } None } else if let Some(username) = self.trick_winner() { @@ -361,7 +363,14 @@ impl Game for KnockOutWhist { } else { None } - } else if let Some(username) = self.dealer.and_then(|dealer| self.seats.player_after(dealer)) { + } else if let Some(username) = self.call { + Some(ValidatedUserAction(UserAction{username, action: Action::WinCall})) + } else { + None + } + } + KnockOutWhistState::RoundCompleted => { + if let Some(username) = self.dealer.and_then(|dealer| self.seats.player_after(dealer)) { Some(ValidatedUserAction(UserAction{username, action: Action::NextToDeal})) } else { None diff --git a/src/main.rs b/src/main.rs index 599a756..a417cb9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -116,9 +116,9 @@ pub async fn handle_client_interest(mut connection: PubSub, mut new_clients: Rec Action::SendInterest{interest} => { debug!("handle_client_interest: Action::SendInterest {{ interest: {:?} }}", interest); if let Ok(interest) = TryFrom::try_from(interest) { - for Client{sender, interests} in &mut clients { + for (index, Client{sender, interests}) in clients.iter_mut().enumerate() { if interests.contains(&interest) { - debug!("handle_client_interest: Sending {:?} to {:?}", interest, sender); + debug!("handle_client_interest: Sending {:?} to clients[{}]", interest, index); if let Err(err) = sender.interest.send(interest.clone()).await { error!("handle_client_interest: Send failed: {}", err); } @@ -163,10 +163,12 @@ async fn handle_new_games(server: Server) -> Result<(), std::io::Error> { match server_state.game_list(games_len).await { Ok(games) => { for game in games { + info!("Starting new game {:?}", game); let id = game.id(); game_list.push(game); let (server_state, update_stream) = server.new_state().await; if let Ok(dealer) = Dealer::new(server_state, id).await { + info!("Spawning new dealer for game {}", id); spawn(dealer.start(update_stream)); } } diff --git a/src/seats.rs b/src/seats.rs index 420744f..f7a0b8c 100644 --- a/src/seats.rs +++ b/src/seats.rs @@ -43,6 +43,25 @@ impl Seats { } } + pub fn player_after_in(&self, username: Username, set: &HashSet) -> Option { + for (&seat, &player) in &self.players { + if player == username { + for (_, &name) in self.players.range(seat+1..) { + if set.contains(&name) { + return Some(name); + } + } + for (_, &name) in self.players.range(..seat) { + if set.contains(&name) { + return Some(name); + } + } + return None; + } + } + None + } + pub fn player_after(&self, username: Username) -> Option { for (&seat, &player) in &self.players { if player == username { diff --git a/src/server.rs b/src/server.rs index 59e6ba4..36e9ed7 100644 --- a/src/server.rs +++ b/src/server.rs @@ -155,7 +155,7 @@ impl ServerState { pub async fn game_list(&mut self, from: usize) -> RedisResult> { debug!("game_list(from: {})", from); let games: Vec> = self.redis.lrange("games", from as isize, -1).await?; - Ok(games.into_iter().map(AsJson::get).enumerate().map(|(id, settings)| GameSummary::new(id as u32, settings)).collect()) + Ok(games.into_iter().map(AsJson::get).enumerate().map(|(id, settings)| GameSummary::new((from + id) as u32, settings)).collect()) } pub async fn game_state(&mut self, id: u32, from: usize) -> RedisResult> {