diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1521f12 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Release Notes + +## Version 2.1 +- clearing the nomination history as the Storyteller clears it for the players too +- vote buttons should work in all situations correctly now +- fixed some minor styling and live session issues + +--- + +## Version 2.0 +- The project is now available under its own domain: [clocktower.online](https://clocktower.online) +- Added a feature that allows a live session Storyteller to automatically (and safely) distribute assigned + characters to all players that have claimed a seat, eliminating the need to manually tell every player their role +- Visible "night phase" that can be toggled by the Storyteller +- Voting history added with nomination and vote results +- Optional, audible voting countdown added (featuring an actual clock tower bell!) +- Fabled show up on the Night Order sheet and affect Grimoire night order counters +- Current game state can now be easily exported and imported in the form of a JSON text code +- Voting can be paused and sped up / slowed down in 0.5 second increments by the Storyteller +- Voting terminology changed to "Hand UP" / "Hand DOWN" and iconography updated +- Added meta-data support for custom scripts, that currently supports `name`, `author` and a custom `logo` through a + `_meta` role (note that a customized logo will not be synced to players in a live session) +- Players can no longer claim seats that are already occupied and only the Storyteller can vacate seats of other players + (players can still vacate their own seat) +- Characters selected in the bluff window now also show up in the list of reminder tokens +- Homebrew scripts / custom characters no longer automatically load in live sessions, for 2 resons: + - the players in a live session have no control over the script that the storyteller loads, + so a malicious storyteller could load a custom script that contains harmful / inappropriate images + - some homebrew scripts are quite big JSON files and synching these through the live session + server can cause traffic / performance issue easily + - this change may be reverted in the future when I figure out a way to sync custom characters safely and without + such a big impact on performance constraints +- Buggy (spamming) live session connections will now be terminated on the server side and display an error message +- Balloonist reminder tokens adjusted +- Live session URLs shortened +- Deus Ex Fiasco and Stormcatcher Fabled added / updated +- Custom Reminder text looks better when there is a lot of text +- added a README for the backend server diff --git a/CNAME b/CNAME new file mode 100644 index 0000000..38f4e8b --- /dev/null +++ b/CNAME @@ -0,0 +1 @@ +clocktower.online diff --git a/README.md b/README.md index a749f05..6e6c836 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ This is an unofficial online tool to run Blood on the Clocktower games through Discord or other digital means. It is supposed to aid storytellers and allow them to quickly set up and capture game states for their players. -[You can try it online!](https://bra1n.github.io/townsquare) +[You can try it online!](https://clocktower.online) To set up a game as the host, check out this tutorial video: [![Tutorial video](https://img.youtube.com/vi/-MyizvdRbVw/0.jpg)](https://www.youtube.com/watch?v=-MyizvdRbVw) @@ -14,12 +14,35 @@ To set up a game as the host, check out this tutorial video: - Public Town Square and Storyteller Grimoire (toggle with **shortcut \[G\]**) - Supports custom script JSON generated by the [Script Tool](https://bloodontheclocktower.com/script) -- Live Session for Storyteller / Players including live voting! -- Includes all 3 base editions and travelers +- Live Session for Storyteller / Players including live voting and character distribution! +- Includes all 3 base editions, Travelers and Fabled - Night sheet and reminder text for each character ability to help storytellers - Many other customization options! -### Custom Characters +### Custom Script Support + +Any custom script generated by the official [Script Tool](https://bloodontheclocktower.com/script) is supported out of +the box and you only need to upload it to get the selected set of characters into your grimoire. If you want to customize +your script further, there is an additional `"_meta"` object that you can add to the script like you would add a normal +character: + +```json +[ + { + "id": "_meta", + "name": "Deadly Penance Day", + "author": "TPI", + "logo": "https://url.to/your/logo.png" + } +] +``` + +This will provide your local Grimoire (and those of your live session players) with more information to show about +your custom script - instead of "Custom Script" it would show "Deadly Penance Day" on the character reference sheet, +for example. The logo is shown only locally, if you want your players to see it as well, they will have to upload the +same JSON file that you used. + +### Custom Character Support In order to add custom characters to your local Grimoire, you need to create a JSON definition for them, similar to what is provided in the [`roles.json`](https://github.com/bra1n/townsquare/blob/main/src/roles.json) for the 3 base editions. Here's an example of how such a character @@ -69,7 +92,8 @@ For base game characters, it is sufficient to only provide the ID, similar to wh - **team**: the team of the character, has to be one of `townsfolk`, `outsider`, `minion`, `demon` or `traveler` - **ability**: the displayed ability text of the character -_Note:_ custom characters are currently not supported in live sessions and will not be synchronised to other players. +_Note:_ in order to use custom characters in live sessions, your players have to load the same JSON file that the storyteller +has loaded before joining the live session. ## [Code of Conduct](CODE_OF_CONDUCT.md) diff --git a/package-lock.json b/package-lock.json index bc8ea18..7fc8fa4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "townsquare", - "version": "1.9.1", + "version": "2.0.1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 8a33968..0a185bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "townsquare", - "version": "1.9.1", + "version": "2.0.1", "description": "Blood on the Clocktower Town Square", "author": "Steffen Baumgart", "scripts": { diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..7d24e55 --- /dev/null +++ b/server/README.md @@ -0,0 +1,53 @@ +## Live session server +This is the home of the NodeJS live session backend. +It allows a Storyteller and their player to communicate through +a Websocket interface with each other. + +In order to run it, you need a recent NodeJS version (v12+) and a set +of SSL Certificate and Key files in order to run the socket through +a secured connection. + +### Local setup + +To run the backend locally, use the following commands from the project root: + +```shell +npm install +cd server/ +NODE_ENV=development node index.js +``` + +This will open the backend server on `localhost:8081` and you +need to adjust your `/src/store/socket.js` file to connect to +the localhost backend. + +### Live setup + +Generate a `cert.pem` and `key.pem` file for the domain that your +live session backend will be available under, for example with [Let's Encrypt](https://letsencrypt.org/). +Copy or symlink these 2 files into your `server/` folder and then run +the following commands from the project root: + +```shell +npm install +cd server/ +node index.js +``` + +This will make the backend server available at your domain on port 8080. +If you want to have it automatically recover on crash or server restart, +you could use [pm2](https://pm2.keymetrics.io/) with the provided `ecosystem.config.js` + +### Allowing access from different domains + +Currently the backend server only accepts connections coming from +pages hosted on github.io or localhost. If you want to use your own +domain for the page, make sure to adjust the domain whitelist pattern +around line 15 in the `index.js`: + +```ecmascript 6 + verifyClient: info => + !!info.origin.match( + /^https?:\/\/([^.]+\.github\.io|localhost|live\.clocktower\.online|eddbra1nprivatetownsquare\.xyz)/i + ) +``` diff --git a/server/index.js b/server/index.js index 4153c45..ae9136f 100644 --- a/server/index.js +++ b/server/index.js @@ -2,6 +2,8 @@ const fs = require("fs"); const https = require("https"); const WebSocket = require("ws"); +const PING_INTERVAL = 30000; // 30 seconds + const server = https.createServer({ cert: fs.readFileSync("cert.pem"), key: fs.readFileSync("key.pem") @@ -9,67 +11,118 @@ const server = https.createServer({ const wss = new WebSocket.Server({ ...(process.env.NODE_ENV === "development" ? { port: 8081 } : { server }), verifyClient: info => + info.origin && !!info.origin.match( - /^https?:\/\/(bra1n\.github\.io|localhost|eddbra1nprivatetownsquare\.xyz)/i + /^https?:\/\/([^.]+\.github\.io|localhost|live\.clocktower\.online|eddbra1nprivatetownsquare\.xyz)/i ) }); function noop() {} +// calculate latency on heartbeat function heartbeat() { this.latency = Math.round((new Date().getTime() - this.pingStart) / 2); + this.counter = 0; this.isAlive = true; } +// map of channels currently in use +const channels = {}; + +// a new client connects wss.on("connection", function connection(ws, req) { - ws.channel = req.url - .split("/") - .pop() - .toLocaleLowerCase(); - if (ws.channel.match(/-host$/i)) { - ws.isHost = true; - ws.channel = ws.channel.substr(0, ws.channel.length - 5); - // check for another host on this channel - if ( - Array.from(wss.clients).some( - client => - client !== ws && - client.readyState === WebSocket.OPEN && - client.channel === ws.channel && - client.isHost - ) - ) { - console.log(ws.channel, "duplicate host"); - ws.close(1000, `The channel "${ws.channel}" already has a host`); - return; - } + // url pattern: clocktower.online// + const url = req.url.toLocaleLowerCase().split("/"); + ws.playerId = url.pop(); + ws.channel = url.pop(); + // check for another host on this channel + if ( + ws.playerId === "host" && + channels[ws.channel] && + channels[ws.channel].some( + client => + client !== ws && + client.readyState === WebSocket.OPEN && + client.playerId === "host" + ) + ) { + console.log(ws.channel, "duplicate host"); + ws.close(1000, `The channel "${ws.channel}" already has a host`); + return; } ws.isAlive = true; ws.pingStart = new Date().getTime(); + ws.counter = 0; + // add channel to list + if (!channels[ws.channel]) { + channels[ws.channel] = []; + } + channels[ws.channel].push(ws); + // start ping pong ws.ping(noop); ws.on("pong", heartbeat); - ws.on("message", function incoming(data) { - const isPing = data.match(/^\["ping/i); - if (!isPing) { - console.log(new Date(), wss.clients.size, ws.channel, data); + // remove client from channels on close + ws.on("close", () => { + const index = channels[ws.channel].indexOf(ws); + if (index >= 0) { + channels[ws.channel].splice(index, 1); } - wss.clients.forEach(function each(client) { - if ( - client !== ws && - client.readyState === WebSocket.OPEN && - client.channel === ws.channel - ) { - // inject latency between both clients if ping message - if (isPing && client.latency && ws.latency) { - client.send(data.replace(/latency/, client.latency + ws.latency)); - } else { - client.send(data); - } + if (!channels[ws.channel].length) delete channels[ws.channel]; + }); + // handle message + ws.on("message", function incoming(data) { + // check rate limit (max 5msg/second) + ws.counter++; + if (ws.counter > (5 * PING_INTERVAL) / 1000) { + console.log(ws.channel, "disconnecting user due to spam"); + ws.close( + 1000, + "Your app seems to be malfunctioning, please clear your browser cache." + ); + return; + } + const messageType = data + .toLocaleLowerCase() + .substr(1) + .split(",", 1) + .pop(); + // don't log ping messages + if (messageType !== '"ping"') { + console.log(new Date(), wss.clients.size, ws.channel, ws.playerId, data); + } + // handle "direct" messages differently + if (messageType === '"direct"') { + try { + const dataToPlayer = JSON.parse(data)[1]; + channels[ws.channel].forEach(function each(client) { + if ( + client !== ws && + client.readyState === WebSocket.OPEN && + dataToPlayer[client.playerId] + ) { + client.send(JSON.stringify(dataToPlayer[client.playerId])); + } + }); + } catch (e) { + console.log("error parsing direct message JSON", e); } - }); + } else { + // all other messages + channels[ws.channel].forEach(function each(client) { + if (client !== ws && client.readyState === WebSocket.OPEN) { + // inject latency between both clients if ping message + if (messageType === '"ping"' && client.latency && ws.latency) { + client.send(data.replace(/latency/, client.latency + ws.latency)); + } else { + client.send(data); + } + } + }); + } }); }); +// start ping interval timer const interval = setInterval(function ping() { wss.clients.forEach(function each(ws) { if (ws.isAlive === false) return ws.terminate(); @@ -77,12 +130,24 @@ const interval = setInterval(function ping() { ws.pingStart = new Date().getTime(); ws.ping(noop); }); -}, 30000); +}, PING_INTERVAL); +// handle server shutdown wss.on("close", function close() { clearInterval(interval); }); +// prod mode with stats API if (process.env.NODE_ENV !== "development") { + console.log("server starting"); server.listen(8080); + server.on("request", (req, res) => { + res.writeHead(200); + res.end( + JSON.stringify({ + players: wss.clients.size, + channels: Object.keys(channels).length + }) + ); + }); } diff --git a/src/App.vue b/src/App.vue index f261468..2556c5b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -3,13 +3,17 @@ id="app" @keyup="keyup" tabindex="-1" - v-bind:class="{ screenshot: grimoire.isScreenshot }" - v-bind:style="{ + :class="{ + screenshot: grimoire.isScreenshot, + night: grimoire.isNight + }" + :style="{ backgroundImage: grimoire.background ? `url('${grimoire.background}')` : '' }" > +
@@ -22,6 +26,8 @@ + + v{{ version }} @@ -41,9 +47,13 @@ import Vote from "./components/Vote"; import Gradients from "./components/Gradients"; import NightOrderModal from "./components/modals/NightOrderModal"; import FabledModal from "@/components/modals/FabledModal"; +import VoteHistoryModal from "@/components/modals/VoteHistoryModal"; +import GameStateModal from "@/components/modals/GameStateModal"; export default { components: { + GameStateModal, + VoteHistoryModal, FabledModal, NightOrderModal, Vote, @@ -148,10 +158,13 @@ body { body { font-size: 1.1em; } - .player .night em { + .night-order em { width: 30px; height: 30px; } + .fabled .night-order.first span { + left: 30px; + } } // Medium devices (tablets, less than 992px) @@ -163,10 +176,13 @@ body { #controls svg { font-size: 20px; } - .player .night em { + .night-order em { width: 20px; height: 20px; } + .fabled .night-order.first span { + left: 20px; + } #townsquare { padding: 10px; } @@ -255,6 +271,7 @@ ul { #version { position: absolute; + text-align: right; right: 10px; bottom: 10px; font-size: 60%; @@ -318,6 +335,7 @@ ul { &.disabled { color: gray; cursor: default; + opacity: 0.75; } &:before, &:after { @@ -326,5 +344,73 @@ ul { width: 10px; height: 10px; } + &.townsfolk { + background: radial-gradient( + at 0 -15%, + rgba(255, 255, 255, 0.07) 70%, + rgba(255, 255, 255, 0) 71% + ) + 0 0/80% 90% no-repeat content-box, + linear-gradient(#0031ad, rgba(5, 0, 0, 0.22)) content-box, + linear-gradient(#292929, #001142) border-box; + box-shadow: inset 0 1px 1px #002c9c, 0 0 10px #000; + &:hover:not(.disabled) { + color: #008cf7; + } + } + &.demon { + background: radial-gradient( + at 0 -15%, + rgba(255, 255, 255, 0.07) 70%, + rgba(255, 255, 255, 0) 71% + ) + 0 0/80% 90% no-repeat content-box, + linear-gradient(#ad0000, rgba(5, 0, 0, 0.22)) content-box, + linear-gradient(#292929, #420000) border-box; + box-shadow: inset 0 1px 1px #9c0000, 0 0 10px #000; + } +} + +/* Night phase backdrop */ +#app > .backdrop { + position: absolute; + left: 0; + right: 0; + bottom: 0; + top: 0; + pointer-events: none; + background: black; + background: linear-gradient( + 180deg, + rgba(0, 0, 0, 1) 0%, + rgba(1, 22, 46, 1) 50%, + rgba(0, 39, 70, 1) 100% + ); + opacity: 0; + transition: opacity 1s ease-in-out; + &:after { + content: " "; + display: block; + width: 100%; + padding-right: 2000px; + height: 100%; + background: url("assets/clouds.png") repeat; + background-size: 2000px auto; + animation: move-background 120s linear infinite; + opacity: 0.3; + } +} + +@keyframes move-background { + from { + transform: translate3d(-2000px, 0px, 0px); + } + to { + transform: translate3d(0px, 0px, 0px); + } +} + +#app.night > .backdrop { + opacity: 0.5; } diff --git a/src/assets/clouds.png b/src/assets/clouds.png new file mode 100644 index 0000000..3841fd8 Binary files /dev/null and b/src/assets/clouds.png differ diff --git a/src/assets/icons/deusexfiasco.png b/src/assets/icons/deusexfiasco.png new file mode 100644 index 0000000..ebc5037 Binary files /dev/null and b/src/assets/icons/deusexfiasco.png differ diff --git a/src/assets/icons/stormcatcher.png b/src/assets/icons/stormcatcher.png index 0ca0c11..97d7948 100644 Binary files a/src/assets/icons/stormcatcher.png and b/src/assets/icons/stormcatcher.png differ diff --git a/src/assets/sounds/countdown.mp3 b/src/assets/sounds/countdown.mp3 new file mode 100644 index 0000000..4d65e25 Binary files /dev/null and b/src/assets/sounds/countdown.mp3 differ diff --git a/src/components/Intro.vue b/src/components/Intro.vue index 8c0bf1b..39e7e35 100644 --- a/src/components/Intro.vue +++ b/src/components/Intro.vue @@ -2,14 +2,19 @@
Welcome to the (unofficial) - Virtual Blood on the Clocktower Town Square!
- Please add more players through the + Virtual Town Square and Grimoire for Blood on the Clocktower! Please + add more players through the Menu - on the top right or by pressing [A].
- This project is free and open source and can be found on - GitHub. + on the top right or by pressing [A]. You can also join a game session + by pressing [J].
+
@@ -33,6 +38,9 @@ export default { border: 3px solid black; border-radius: 10px; z-index: 3; + a { + color: white; + } img { position: absolute; bottom: 100%; @@ -45,5 +53,9 @@ export default { box-shadow: 0 0 10px black; border: 3px solid black; } + .footer { + font-size: 60%; + opacity: 0.75; + } } diff --git a/src/components/Menu.vue b/src/components/Menu.vue index 1922a7f..734a8e3 100644 --- a/src/components/Menu.vue +++ b/src/components/Menu.vue @@ -26,7 +26,7 @@ :class="{ success: grimoire.isScreenshotSuccess }" /> -