diff --git a/README.md b/README.md index b4153a4..66bf448 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,10 @@ smoothly when you don't have a deck, or when you and your friends are together v Ultimate Werewolf and by 2020's quarantine. The app is free to use and anonymous. +After a long hiatus from maintaining the application, I have come back and undertaken a large-scale redesign, rewriting +most of the code and producing a result that I believe is more stable and has much more sensible client-server interaction. +It's a shame that my first attempt is what ended up in Github's Artic Code Vault :) +  ## Features @@ -23,10 +27,9 @@ The application prioritizes responsiveness. A key scenario would be when a group ## Tech Stack This is a Node.js application. It is written purely using JavaScript/HTML/CSS. The main dependencies are -Express.js and Socket.io. It is fully open source +Express.js and Socket.io. It is fully open-source and under the MIT license. This was (and still is) fundamentally a learning project, and thus I welcome collaboration -and feedback of any kind. After a long break of maintaining the application I am back to work on re-designing and -improving it. +and feedback of any kind. All pixel art is my own, for better or for worse. @@ -65,7 +68,7 @@ consulting these below. ### CLI Options These options will be at the end of your run command following two dashes e.g. `npm run start:dev -- [options]`. -Options are key-value pairs with the syntax `[key]=[value]` e.g. `port=4242`. Options include: +Options are whitespace-delimited key-value pairs with the syntax `[key]=[value]` e.g. `port=4242`. Options include: - `port`. Specify an integer port for the application. - `loglevel` the log level for the application. Can be `info`, `error`, `warn`, `debug`, or `trace`. diff --git a/client/src/config/globals.js b/client/src/config/globals.js index f4293f5..47b16e9 100644 --- a/client/src/config/globals.js +++ b/client/src/config/globals.js @@ -1,6 +1,8 @@ export const globals = { USER_SIGNATURE_LENGTH: 25, CLOCK_TICK_INTERVAL_MILLIS: 10, + MAX_CUSTOM_ROLE_NAME_LENGTH: 30, + MAX_CUSTOM_ROLE_DESCRIPTION_LENGTH: 500, TOAST_DURATION_DEFAULT: 6, ACCESS_CODE_LENGTH: 6, PLAYER_ID_COOKIE_KEY: 'play-werewolf-anon-id', diff --git a/client/src/images/add.svg b/client/src/images/add.svg new file mode 100644 index 0000000..b84df19 --- /dev/null +++ b/client/src/images/add.svg @@ -0,0 +1 @@ + diff --git a/client/src/images/delete.svg b/client/src/images/delete.svg new file mode 100644 index 0000000..9a8137f --- /dev/null +++ b/client/src/images/delete.svg @@ -0,0 +1,14 @@ + diff --git a/client/src/images/info.svg b/client/src/images/info.svg index c997041..a09de5e 100644 --- a/client/src/images/info.svg +++ b/client/src/images/info.svg @@ -1,14 +1 @@ - + diff --git a/client/src/images/pencil.svg b/client/src/images/pencil.svg new file mode 100644 index 0000000..355b4f4 --- /dev/null +++ b/client/src/images/pencil.svg @@ -0,0 +1,14 @@ + diff --git a/client/src/modules/DeckStateManager.js b/client/src/modules/DeckStateManager.js index cb6376a..b0bf0a9 100644 --- a/client/src/modules/DeckStateManager.js +++ b/client/src/modules/DeckStateManager.js @@ -1,3 +1,7 @@ +import { globals } from "../config/globals.js"; +import {toast} from "./Toast.js"; +import {ModalManager} from "./ModalManager"; + export class DeckStateManager { constructor() { this.deck = null; @@ -15,6 +19,16 @@ export class DeckStateManager { addToCustomRoleOptions(role) { this.customRoleOptions.push(role); + localStorage.setItem("play-werewolf-custom-roles", JSON.stringify(this.customRoleOptions.concat(this.deck.filter(card => card.custom === true)))); + } + + removeFromCustomRoleOptions(name) { + let option = this.customRoleOptions.find((option) => option.role === name); + if (option) { + this.customRoleOptions.splice(this.customRoleOptions.indexOf(option), 1); + localStorage.setItem("play-werewolf-custom-roles", JSON.stringify(this.customRoleOptions.concat(this.deck.filter(card => card.custom === true)))); + toast('"' + name + '" deleted.', 'error', true, true, 3); + } } addCopyOfCard(role) { @@ -54,4 +68,82 @@ export class DeckStateManager { } return total; } + + loadCustomRolesFromCookies() { + let customRoles = localStorage.getItem('play-werewolf-custom-roles'); + if (customRoles !== null && validateCustomRoleCookie(customRoles)) { + this.customRoleOptions = JSON.parse(customRoles); // we know it is valid JSON from the validate function + } + } + + loadCustomRolesFromFile(file, updateRoleListFunction, loadDefaultCardsFn, showIncludedCardsFn) { + let reader = new FileReader(); + reader.onerror = (e) => { + toast(reader.error.message, "error", true, true, 5); + } + reader.onload = (e) => { + let string; + if (typeof e.target.result !== "string") { + string = new TextDecoder("utf-8").decode(e.target.result); + } else { + string = e.target.result; + } + if (validateCustomRoleCookie(string)) { + this.customRoleOptions = JSON.parse(string); // we know it is valid JSON from the validate function + ModalManager.dispelModal("upload-custom-roles-modal", "modal-background"); + toast("Roles imported successfully", "success", true, true, 3); + localStorage.setItem("play-werewolf-custom-roles", JSON.stringify(this.customRoleOptions)); + updateRoleListFunction(this, document.getElementById("deck-select")); + // loadDefaultCardsFn(this); + // showIncludedCardsFn(this); + } else { + toast("Invalid formatting. Make sure you import the file as downloaded from this page.", "error", true, true, 5); + } + } + reader.readAsText(file); + } + + // via https://stackoverflow.com/a/18197341 + downloadCustomRoles(filename, text) { + let element = document.createElement('a'); + element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text)); + element.setAttribute('download', filename); + + element.style.display = 'none'; + document.body.appendChild(element); + + element.click(); + + document.body.removeChild(element); + } + +} + +// this is user-supplied, so we should validate it fully +function validateCustomRoleCookie(cookie) { + let valid = false; + if (typeof cookie === "string" && new Blob([cookie]).size <= 1000000) { + try { + let cookieJSON = JSON.parse(cookie); + if (Array.isArray(cookieJSON)) { + for (let entry of cookieJSON) { + if (typeof entry === "object") { + if (typeof entry.role !== "string" || entry.role.length > globals.MAX_CUSTOM_ROLE_NAME_LENGTH + || typeof entry.team !== "string" || (entry.team !== globals.ALIGNMENT.GOOD && entry.team !== globals.ALIGNMENT.EVIL) + || typeof entry.description !== "string" || entry.description.length > globals.MAX_CUSTOM_ROLE_DESCRIPTION_LENGTH + ) { + return false; + } + } else { + return false; + } + } + return true; + } + } catch(e) { + return false; + } + } + + return valid; } diff --git a/client/src/modules/GameCreationStepManager.js b/client/src/modules/GameCreationStepManager.js index 2ee447e..a67208b 100644 --- a/client/src/modules/GameCreationStepManager.js +++ b/client/src/modules/GameCreationStepManager.js @@ -5,9 +5,12 @@ import { ModalManager } from "./ModalManager.js"; import {XHRUtility} from "./XHRUtility.js"; import {globals} from "../config/globals.js"; import {templates} from "./Templates.js"; +import {defaultCards} from "../config/defaultCards"; export class GameCreationStepManager { constructor(deckManager) { + loadDefaultCards(deckManager); + deckManager.loadCustomRolesFromCookies(); this.step = 1; this.currentGame = new Game(null, null, null, null); this.deckManager = deckManager; @@ -197,6 +200,35 @@ function renderRoleSelectionStep(game, containerId, step, deckManager) { stepContainer.innerHTML += templates.CREATE_GAME_DECK; document.getElementById(containerId).appendChild(stepContainer); + document.querySelector('#custom-roles-export').addEventListener('click', (e) => { + e.preventDefault(); + deckManager.downloadCustomRoles('play-werewolf-custom-roles', JSON.stringify(deckManager.getCurrentCustomRoleOptions())); + }); + + document.querySelector('#custom-roles-import').addEventListener('click', (e) => { + e.preventDefault(); + ModalManager.displayModal("upload-custom-roles-modal", "modal-background", "close-upload-custom-roles-modal-button"); + }); + + document.getElementById("upload-custom-roles-form").onsubmit = (e) => { + e.preventDefault(); + let fileList = document.getElementById("upload-custom-roles").files; + if (fileList.length > 0) { + let file = fileList[0]; + if (file.size > 1000000) { + toast("Your file is too large (max 1MB)", "error", true, true, 5); + return; + } + if (file.type !== "text/plain") { + toast("Your file must be a text file", "error", true, true, 5); + return; + } + + deckManager.loadCustomRolesFromFile(file, updateCustomRoleOptionsList, loadDefaultCards, showIncludedCards); + } else { + toast("You must upload a text file", "error", true, true, 5); + } + } let clickHandler = () => { console.log("fired"); @@ -208,9 +240,9 @@ function renderRoleSelectionStep(game, containerId, step, deckManager) { } }; - //document.getElementById("custom-role-hamburger").addEventListener("click", clickHandler); + document.getElementById("custom-role-hamburger").addEventListener("click", clickHandler); - loadIncludedCards(deckManager); + showIncludedCards(deckManager); loadCustomRoles(deckManager); @@ -354,8 +386,9 @@ function showButtons(back, forward, forwardHandler, backHandler, builtGame=null) } // Display a widget for each default card that allows copies of it to be added/removed. Set initial deck state. -function loadIncludedCards(deckManager) { - for (let i = 0; i < deckManager.getCurrentDeck().length; i ++) { // each dropdown should include every +function showIncludedCards(deckManager) { + document.querySelectorAll('.compact-card').forEach((el) => { el.remove() }); + for (let i = 0; i < deckManager.getCurrentDeck().length; i ++) { let card = deckManager.getCurrentDeck()[i]; let cardEl = constructCompactDeckBuilderElement(card, deckManager); if (card.team === globals.ALIGNMENT.GOOD) { @@ -370,25 +403,23 @@ function loadIncludedCards(deckManager) { create a widget for it */ function loadCustomRoles(deckManager) { let select = document.getElementById("deck-select"); - addOptionsToList(deckManager.getCurrentCustomRoleOptions(), document.getElementById("deck-select")); - document.getElementById("include-role").addEventListener('click', (e) => { - e.preventDefault(); - if (select.value && select.value.length > 0) { - if (!deckManager.getCard(select.value)) { - deckManager.addToDeck(select.value); - let cardEl = constructCompactDeckBuilderElement(deckManager.getCard(select.value), deckManager); - toast('"' + select.value + '" included.', 'success', true, true, 3); - if (deckManager.getCard(select.value).team === globals.ALIGNMENT.GOOD) { - document.getElementById("deck-good").appendChild(cardEl); - } else { - document.getElementById("deck-evil").appendChild(cardEl); - } - updateCustomRoleOptionsList(deckManager, select); - } else { - toast('"' + select.value + '" already included.', 'error', true, true, 3); - } + addOptionsToList(deckManager, document.getElementById("deck-select")); +} + +function loadDefaultCards(deckManager) { + defaultCards.sort((a, b) => { + if (a.team !== b.team) { + return a.team === globals.ALIGNMENT.GOOD ? 1 : -1; } - }) + return a.role.localeCompare(b.role); + }); + let deck = []; + for (let i = 0; i < defaultCards.length; i ++) { + let card = defaultCards[i]; + card.quantity = 0; + deck.push(card); + } + deckManager.deck = deck; } function constructCompactDeckBuilderElement(card, deckManager) { @@ -453,9 +484,9 @@ function initializeRemainingEventListeners(deckManager) { toast('Your description is too long (max 500 characters).', "error", true); return; } - deckManager.addToCustomRoleOptions({role: name, description: description, team: team}); + deckManager.addToCustomRoleOptions({role: name, description: description, team: team, custom: true}); updateCustomRoleOptionsList(deckManager, document.getElementById("deck-select")) - ModalManager.dispelModal("add-role-modal", "add-role-modal-background"); + ModalManager.dispelModal("add-role-modal", "modal-background"); toast("Role Created", "success", true); } else { toast("There is already a role with this name", "error", true, true, 3); @@ -465,7 +496,7 @@ function initializeRemainingEventListeners(deckManager) { "click", () => { ModalManager.displayModal( "add-role-modal", - "add-role-modal-background", + "modal-background", "close-modal-button" ) } @@ -473,11 +504,12 @@ function initializeRemainingEventListeners(deckManager) { } function updateCustomRoleOptionsList(deckManager, selectEl) { - document.querySelectorAll('#deck-select option').forEach(e => e.remove()); - addOptionsToList(deckManager.customRoleOptions, selectEl); + document.querySelectorAll('#deck-select .deck-select-role').forEach(e => e.remove()); + addOptionsToList(deckManager, selectEl); } -function addOptionsToList(options, selectEl) { +function addOptionsToList(deckManager, selectEl) { + let options = deckManager.getCurrentCustomRoleOptions(); options.sort((a, b) => { if (a.team !== b.team) { return a.team === globals.ALIGNMENT.GOOD ? 1 : -1; @@ -485,29 +517,72 @@ function addOptionsToList(options, selectEl) { return a.role.localeCompare(b.role); }); for (let i = 0; i < options.length; i ++) { - let optionEl = document.createElement("option"); + let optionEl = document.createElement("div"); + optionEl.innerHTML = templates.DECK_SELECT_ROLE; + optionEl.classList.add('deck-select-role'); let alignmentClass = options[i].team === globals.ALIGNMENT.GOOD ? globals.ALIGNMENT.GOOD : globals.ALIGNMENT.EVIL optionEl.classList.add(alignmentClass); - optionEl.setAttribute("value", options[i].role); - optionEl.innerText = options[i].role; + optionEl.querySelector('.deck-select-role-name').innerText = options[i].role; selectEl.appendChild(optionEl); } + + addCustomRoleEventListeners(deckManager, selectEl); +} + +function addCustomRoleEventListeners(deckManager, select) { + document.querySelectorAll('.deck-select-role').forEach((role) => { + let name = role.querySelector('.deck-select-role-name').innerText; + role.querySelector('.deck-select-include').addEventListener('click', (e) => { + e.preventDefault(); + if (!deckManager.getCard(name)) { + deckManager.addToDeck(name); + let cardEl = constructCompactDeckBuilderElement(deckManager.getCard(name), deckManager); + toast('"' + name + '" included.', 'success', true, true, 3); + if (deckManager.getCard(name).team === globals.ALIGNMENT.GOOD) { + document.getElementById("deck-good").appendChild(cardEl); + } else { + document.getElementById("deck-evil").appendChild(cardEl); + } + updateCustomRoleOptionsList(deckManager, select); + } else { + toast('"' + select.value + '" already included.', 'error', true, true, 3); + } + }); + + role.querySelector('.deck-select-remove').addEventListener('click', (e) => { + if (confirm("Delete the role '" + name + "'?")) { + e.preventDefault(); + deckManager.removeFromCustomRoleOptions(name); + updateCustomRoleOptionsList(deckManager, select); + } + }) + }); } function updateDeckStatus(deckManager) { document.querySelectorAll('.deck-role').forEach((el) => el.remove()); document.getElementById("deck-count").innerText = deckManager.getDeckSize() + " Players"; - for (let card of deckManager.getCurrentDeck()) { - if (card.quantity > 0) { - let roleEl = document.createElement("div"); - roleEl.classList.add('deck-role'); - if (card.team === globals.ALIGNMENT.GOOD) { - roleEl.classList.add(globals.ALIGNMENT.GOOD); - } else { - roleEl.classList.add(globals.ALIGNMENT.EVIL); + if (deckManager.getDeckSize() === 0) { + let placeholder = document.createElement("div"); + placeholder.setAttribute("id", "deck-list-placeholder"); + placeholder.innerText = "Add a card from the included roles below."; + document.getElementById("deck-list").appendChild(placeholder); + } else { + if (document.getElementById("deck-list-placeholder")) { + document.getElementById("deck-list-placeholder").remove(); + } + for (let card of deckManager.getCurrentDeck()) { + if (card.quantity > 0) { + let roleEl = document.createElement("div"); + roleEl.classList.add('deck-role'); + if (card.team === globals.ALIGNMENT.GOOD) { + roleEl.classList.add(globals.ALIGNMENT.GOOD); + } else { + roleEl.classList.add(globals.ALIGNMENT.EVIL); + } + roleEl.innerText = card.quantity + 'x ' + card.role; + document.getElementById("deck-list").appendChild(roleEl); } - roleEl.innerText = card.quantity + 'x ' + card.role; - document.getElementById("deck-list").appendChild(roleEl); } } } diff --git a/client/src/modules/Templates.js b/client/src/modules/Templates.js index c3b69b3..06e231d 100644 --- a/client/src/modules/Templates.js +++ b/client/src/modules/Templates.js @@ -227,38 +227,40 @@ export const templates = { CREATE_GAME_DECK: "