Merge pull request #2 from bra1n/main

update my fork
This commit is contained in:
nicfreeman1209 2021-03-29 10:37:52 +01:00 committed by GitHub
commit d3b2f167ef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 14660 additions and 2089 deletions

20
.github/workflows/changelog-check.yml vendored Normal file
View File

@ -0,0 +1,20 @@
name: Enforce Changelog Update
on:
pull_request:
types: [assigned, opened, synchronize, reopened, labeled, unlabeled]
branches:
- main
jobs:
build:
name: Check Actions
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Changelog check
uses: Zomzog/changelog-checker@v1.2.0
with:
fileName: CHANGELOG.md
noChangelogLabel: no changelog
checkNotification: Simple
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -1,5 +1,48 @@
# Release Notes # Release Notes
### Version 2.10.0
- added [nomination log indicator](https://fontawesome.com/icons/book-dead). When a nomination log [v] is available, the number of currently visible entries is displayed. Clicking the indicator can reveal/hide the nomination log.
- fix issue where a player and storyteller updating the same players pronouns at around the same time causes an infinite loop disconnecting the session.
- fix bug with shifting roles when the storyteller deletes a player
- added Poppygrower to list of available characters
---
### Version 2.9.1
- fix gamestate JSON not showing (custom) roles and failing to load states with custom scripts properly
- fix gamestate not stripping out special characters from role.id on load
- made character assignment modal a bit prettier
- got rid of the extra pixels on the Soldier icon
- fixed lengthy live session channel names not being correctly cut off
- hide player names in night order / character reference popup when town square is public
- fix (pre-)vote calculation being off by one if the nominee votes
---
### Version 2.9.0
- added support for assigning pronouns to players and display of the pronouns in a tooltip on the player name.
- added button to modals that allows the user to maximize them
- added Mephit and Snitch to roles.json
---
### Version 2.8.0
- added hands-off live session support for homebrew / custom characters again!
- added custom image opt-in that will prevent any (potentially malicious / harmful) images from loading until a player manually allows them to
---
## Version 2.7.0
- added support for assigning duplicate characters to more than one player (like Legion)
- further live session bandwidth optimizations
- sessions can now be joined by pasting the whole link into the popup (thanks @davotronic5000)
- fabled night order bug fixed
- added Legion to list of available characters (thanks @eddgabriel)
- added support for mp4/webm video backgrounds
- added tooltips to night order popup
---
## Version 2.6.0 ## Version 2.6.0
- night mode can be toggeled with [S] now (thanks @davotronic5000) - night mode can be toggeled with [S] now (thanks @davotronic5000)
- night order shows which players are dead - night order shows which players are dead

View File

@ -47,7 +47,9 @@ The development server can be started with `npm run serve`.
Deploying a forked version to GitHub Pages or running your local Deploying a forked version to GitHub Pages or running your local
development copy in a sub-path (instead of the web root) will require you to modify development copy in a sub-path (instead of the web root) will require you to modify
the `vue.config.js` and configure the path at which the website will be served. the `vue.config.js` and configure the path at which the website will be served, as well
as removing the CNAME file and updating the GitHub pages configuration. Otherwise, your fork
will think it should be served at clocktower.online instead of \<user\>.github.io/townsquare/.
For example, deploying your forked `townsquare` project to GitHub pages would need the following For example, deploying your forked `townsquare` project to GitHub pages would need the following
`vue.config.js` changes: `vue.config.js` changes:

View File

@ -3,11 +3,11 @@
![social](https://user-images.githubusercontent.com/325521/102897760-d1147b00-4468-11eb-9d7b-63a204bc9fc1.png) ![social](https://user-images.githubusercontent.com/325521/102897760-d1147b00-4468-11eb-9d7b-63a204bc9fc1.png)
This is an unofficial online tool to run Blood on the Clocktower games through Discord or other digital means. 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. It is supposed to aid storytellers and players by allowing them to quickly set up games, run votes and much more.
[You can try it online!](https://clocktower.online) [You can try it online!](https://clocktower.online)
If you want to learn more about how to use the website as a player, [JayBotC](https://www.youtube.com/channel/UCNZy-4Rp877XtTHaIZdWYFQ) kindly created two tutorial videos. If you want to learn more about how to use the app as a player, [JayBotC](https://www.youtube.com/channel/UCNZy-4Rp877XtTHaIZdWYFQ) kindly created two tutorial videos.
### How to host a game ### How to host a game
[![How to host a game](https://img.youtube.com/vi/lVRJPBXfqxg/0.jpg)](https://www.youtube.com/watch?v=lVRJPBXfqxg) [![How to host a game](https://img.youtube.com/vi/lVRJPBXfqxg/0.jpg)](https://www.youtube.com/watch?v=lVRJPBXfqxg)
@ -20,8 +20,9 @@ If you want to learn more about how to use the website as a player, [JayBotC](ht
- Public Town Square and Storyteller Grimoire (toggle with **shortcut \[G\]**) - Public Town Square and Storyteller Grimoire (toggle with **shortcut \[G\]**)
- Supports custom script JSON generated by the [Script Tool](https://bloodontheclocktower.com/script) - Supports custom script JSON generated by the [Script Tool](https://bloodontheclocktower.com/script)
- Live Session for Storyteller / Players including live voting and character distribution! - Live Session for Storyteller / Players including live voting and character distribution!
- Includes all 3 base editions, Travelers and Fabled - Includes all 3 base editions, Travelers and Fabled plus all officially spoiled characters so far!
- Night sheet and reminder text for each character ability to help storytellers - Night sheet and reminder text for each character ability to help storytellers
- Full homebrew support for hosting and playing games with your own sets of characters
- Many other customization options! - Many other customization options!
### Custom Script Support ### Custom Script Support
@ -44,8 +45,7 @@ character:
This will provide your local Grimoire (and those of your live session players) with more information to show about 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, 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 for example. The logo will be shown to your players after they have enabled custom images in the Grimoire menu.
same JSON file that you used.
### Custom Character Support ### Custom Character Support
@ -84,8 +84,10 @@ For base game characters, it is sufficient to only provide the ID, similar to wh
**Required properties:** `id`, `name`, `team`, `ability` **Required properties:** `id`, `name`, `team`, `ability`
- **id**: the internal ID for this character, without spaces or special characters - **id**: the internal ID for this character, without spaces or special characters<br>
- **image**: a URL to a PNG of the character token icon (should have a transparent background!) _Note_: this ID needs to be unique and can't be the same as any ID already used by an existing character, otherwise the custom character will be overwritten with the existing role!
- **image**: a URL to a PNG of the character token icon (should have a transparent background!)<br>
_Note_: custom images will only be visible after enabling them in the Grimoire menu!
- **edition**: the ID of the edition for this character. can be left blank or "custom" - **edition**: the ID of the edition for this character. can be left blank or "custom"
- **firstNight** / **otherNight**: the position that this character acts on the first / other nights, compared to all - **firstNight** / **otherNight**: the position that this character acts on the first / other nights, compared to all
other characters other characters
@ -97,9 +99,6 @@ 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` - **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 - **ability**: the displayed ability text of the character
_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) ## [Code of Conduct](CODE_OF_CONDUCT.md)
## [Contributing](CONTRIBUTING.md) ## [Contributing](CONTRIBUTING.md)

15265
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "townsquare", "name": "townsquare",
"version": "2.6.0", "version": "2.10.0",
"description": "Blood on the Clocktower Town Square", "description": "Blood on the Clocktower Town Square",
"author": "Steffen Baumgart", "author": "Steffen Baumgart",
"scripts": { "scripts": {

View File

@ -150,40 +150,63 @@ wss.on("connection", function connection(ws, req) {
.substr(1) .substr(1)
.split(",", 1) .split(",", 1)
.pop(); .pop();
// don't log ping messages switch (messageType) {
if (messageType !== '"ping"') { case '"ping"':
console.log(new Date(), wss.clients.size, ws.channel, ws.playerId, data); // ping messages will only be sent host -> all or all -> host
}
// handle "direct" messages differently
if (messageType === '"direct"') {
try {
const dataToPlayer = JSON.parse(data)[1];
channels[ws.channel].forEach(function each(client) { channels[ws.channel].forEach(function each(client) {
if ( if (
client !== ws && client !== ws &&
client.readyState === WebSocket.OPEN && client.readyState === WebSocket.OPEN &&
dataToPlayer[client.playerId] (ws.playerId === "host" || client.playerId === "host")
) { ) {
client.send(JSON.stringify(dataToPlayer[client.playerId])); client.send(
data.replace(/latency/, (client.latency || 0) + (ws.latency || 0))
);
metrics.messages_outgoing.inc(); metrics.messages_outgoing.inc();
} }
}); });
} catch (e) { break;
console.log("error parsing direct message JSON", e); case '"direct"':
} // handle "direct" messages differently
} else { console.log(
// all other messages new Date(),
channels[ws.channel].forEach(function each(client) { wss.clients.size,
if (client !== ws && client.readyState === WebSocket.OPEN) { ws.channel,
// inject latency between both clients if ping message ws.playerId,
if (messageType === '"ping"' && client.latency && ws.latency) { data
client.send(data.replace(/latency/, client.latency + ws.latency)); );
} else { try {
client.send(data); const dataToPlayer = JSON.parse(data)[1];
} channels[ws.channel].forEach(function each(client) {
metrics.messages_outgoing.inc(); if (
client !== ws &&
client.readyState === WebSocket.OPEN &&
dataToPlayer[client.playerId]
) {
client.send(JSON.stringify(dataToPlayer[client.playerId]));
metrics.messages_outgoing.inc();
}
});
} catch (e) {
console.log("error parsing direct message JSON", e);
} }
}); break;
default:
// all other messages
console.log(
new Date(),
wss.clients.size,
ws.channel,
ws.playerId,
data
);
channels[ws.channel].forEach(function each(client) {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(data);
metrics.messages_outgoing.inc();
}
});
break;
} }
}); });
}); });

View File

@ -10,6 +10,13 @@
: '' : ''
}" }"
> >
<video
id="background"
v-if="grimoire.background && grimoire.background.match(/\.(mp4|webm)$/i)"
:src="grimoire.background"
autoplay
loop
></video>
<div class="backdrop"></div> <div class="backdrop"></div>
<transition name="blur"> <transition name="blur">
<Intro v-if="!players.length"></Intro> <Intro v-if="!players.length"></Intro>
@ -299,6 +306,14 @@ ul {
} }
} }
/* video background */
video#background {
position: absolute;
width: 100%;
height: 100%;
object-fit: cover;
}
/* Night phase backdrop */ /* Night phase backdrop */
#app > .backdrop { #app > .backdrop {
position: absolute; position: absolute;

BIN
src/assets/icons/legion.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

BIN
src/assets/icons/mephit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

BIN
src/assets/icons/minion.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

BIN
src/assets/icons/snitch.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 76 KiB

View File

@ -1,5 +1,18 @@
<template> <template>
<div id="controls"> <div id="controls">
<span
class="nomlog-summary"
v-show="session.voteHistory.length && session.sessionId"
@click="toggleModal('voteHistory')"
:title="
`${session.voteHistory.length} recent ${
session.voteHistory.length == 1 ? 'nomination' : 'nominations'
}`
"
>
<font-awesome-icon icon="book-dead" />
{{ session.voteHistory.length }}
</span>
<span <span
class="session" class="session"
:class="{ :class="{
@ -69,11 +82,21 @@
/> />
</em> </em>
</li> </li>
<li v-if="!edition.isOfficial" @click="imageOptIn">
<small>Show Custom Images</small>
<em
><font-awesome-icon
:icon="[
'fas',
grimoire.isImageOptIn ? 'check-square' : 'square'
]"
/></em>
</li>
<li @click="setBackground"> <li @click="setBackground">
Background image Background image
<em><font-awesome-icon icon="image"/></em> <em><font-awesome-icon icon="image"/></em>
</li> </li>
<li @click="toggleMute"> <li @click="toggleMuted">
Mute Sounds Mute Sounds
<em <em
><font-awesome-icon ><font-awesome-icon
@ -83,40 +106,41 @@
</template> </template>
<template v-if="tab === 'session'"> <template v-if="tab === 'session'">
<!-- Session -->
<li class="headline" v-if="session.sessionId"> <li class="headline" v-if="session.sessionId">
{{ session.isSpectator ? "Playing" : "Hosting" }} {{ session.isSpectator ? "Playing" : "Hosting" }}
</li> </li>
<li class="headline" v-else> <li class="headline" v-else>
Live Session Live Session
</li> </li>
<li @click="hostSession" v-if="!session.sessionId"> <template v-if="!session.sessionId">
Host (Storyteller)<em>[H]</em> <li @click="hostSession">Host (Storyteller)<em>[H]</em></li>
</li> <li @click="joinSession">Join (Player)<em>[J]</em></li>
<li @click="joinSession" v-if="!session.sessionId"> </template>
Join (Player)<em>[J]</em> <template v-else>
</li> <li v-if="session.ping">
<li v-if="session.sessionId && session.ping"> Delay to {{ session.isSpectator ? "host" : "players" }}
Delay to {{ session.isSpectator ? "host" : "players" }} <em>{{ session.ping }}ms</em>
<em>{{ session.ping }}ms</em> </li>
</li> <li @click="copySessionUrl">
<li v-if="session.sessionId" @click="copySessionUrl"> Copy player link
Copy player link <em><font-awesome-icon icon="copy"/></em>
<em><font-awesome-icon icon="copy"/></em> </li>
</li> <li v-if="!session.isSpectator" @click="distributeRoles">
<li v-if="!session.isSpectator" @click="distributeRoles"> Send Characters
Send Characters <em><font-awesome-icon icon="theater-masks"/></em>
<em><font-awesome-icon icon="theater-masks"/></em> </li>
</li> <li
<li v-if="session.voteHistory.length"
v-if="session.voteHistory.length" @click="toggleModal('voteHistory')"
@click="toggleModal('voteHistory')" >
> Nomination history<em>[V]</em>
Nomination history<em>[V]</em> </li>
</li> <li @click="leaveSession">
<li @click="leaveSession" v-if="session.sessionId"> Leave Session
Leave Session <em>{{ session.sessionId }}</em>
<em>{{ session.sessionId }}</em> </li>
</li> </template>
</template> </template>
<template v-if="tab === 'players' && !session.isSpectator"> <template v-if="tab === 'players' && !session.isSpectator">
@ -203,7 +227,7 @@ import { mapMutations, mapState } from "vuex";
export default { export default {
computed: { computed: {
...mapState(["grimoire", "session"]), ...mapState(["grimoire", "session", "edition"]),
...mapState("players", ["players"]) ...mapState("players", ["players"])
}, },
data() { data() {
@ -218,9 +242,6 @@ export default {
this.$store.commit("setBackground", background); this.$store.commit("setBackground", background);
} }
}, },
toggleMute() {
this.$store.commit("setIsMuted", !this.grimoire.isMuted);
},
hostSession() { hostSession() {
if (this.session.sessionId) return; if (this.session.sessionId) return;
const sessionId = prompt( const sessionId = prompt(
@ -253,11 +274,21 @@ export default {
); );
} }
}, },
imageOptIn() {
const popup =
"Are you sure you want to allow custom images? A malicious script file author might track your IP address this way.";
if (this.grimoire.isImageOptIn || confirm(popup)) {
this.toggleImageOptIn();
}
},
joinSession() { joinSession() {
if (this.session.sessionId) return this.leaveSession(); if (this.session.sessionId) return this.leaveSession();
const sessionId = prompt( let sessionId = prompt(
"Enter the channel number / name of the session you want to join" "Enter the channel number / name of the session you want to join"
); );
if (sessionId.match(/^https?:\/\//i)) {
sessionId = sessionId.split("#").pop();
}
if (sessionId) { if (sessionId) {
this.$store.commit("session/clearVoteHistory"); this.$store.commit("session/clearVoteHistory");
this.$store.commit("session/setSpectator", true); this.$store.commit("session/setSpectator", true);
@ -299,6 +330,8 @@ export default {
...mapMutations([ ...mapMutations([
"toggleGrimoire", "toggleGrimoire",
"toggleMenu", "toggleMenu",
"toggleImageOptIn",
"toggleMuted",
"toggleNight", "toggleNight",
"toggleNightOrder", "toggleNightOrder",
"setZoom", "setZoom",
@ -346,6 +379,10 @@ export default {
margin-left: 10px; margin-left: 10px;
} }
span.nomlog-summary {
color: $townsfolk;
}
span.session { span.session {
color: $demon; color: $demon;
&.spectator { &.spectator {

View File

@ -23,7 +23,7 @@
> >
<em>{{ nightOrder.get(player).first }}.</em> <em>{{ nightOrder.get(player).first }}.</em>
<span v-if="player.role.firstNightReminder">{{ <span v-if="player.role.firstNightReminder">{{
player.role.firstNightReminder | handleEmojis player.role.firstNightReminder
}}</span> }}</span>
</div> </div>
<div <div
@ -32,7 +32,7 @@
> >
<em>{{ nightOrder.get(player).other }}.</em> <em>{{ nightOrder.get(player).other }}.</em>
<span v-if="player.role.otherNightReminder">{{ <span v-if="player.role.otherNightReminder">{{
player.role.otherNightReminder | handleEmojis player.role.otherNightReminder
}}</span> }}</span>
</div> </div>
@ -97,17 +97,29 @@
@click="updatePlayer('isVoteless', true)" @click="updatePlayer('isVoteless', true)"
title="Ghost vote" title="Ghost vote"
/> />
<div <div
class="name" class="name"
@click="isMenuOpen = !isMenuOpen" @click="isMenuOpen = !isMenuOpen"
:class="{ active: isMenuOpen }" :class="{ active: isMenuOpen }"
> >
{{ player.name }} <span>{{ player.name }}</span>
<font-awesome-icon icon="venus-mars" v-if="player.pronouns" />
<div class="pronouns" v-if="player.pronouns">
<span>{{ player.pronouns }}</span>
</div>
</div> </div>
<transition name="fold"> <transition name="fold">
<ul class="menu" v-if="isMenuOpen"> <ul class="menu" v-if="isMenuOpen">
<li
@click="changePronouns"
v-if="
!session.isSpectator ||
(session.isSpectator && player.id === session.playerId)
"
>
<font-awesome-icon icon="venus-mars" />Change Pronouns
</li>
<template v-if="!session.isSpectator"> <template v-if="!session.isSpectator">
<li @click="changeName"> <li @click="changeName">
<font-awesome-icon icon="user-edit" />Rename <font-awesome-icon icon="user-edit" />Rename
@ -124,10 +136,6 @@
<font-awesome-icon icon="exchange-alt" /> <font-awesome-icon icon="exchange-alt" />
Swap seats Swap seats
</li> </li>
<li @click="removePlayer">
<font-awesome-icon icon="times-circle" />
Remove
</li>
<li <li
@click="updatePlayer('id', '', true)" @click="updatePlayer('id', '', true)"
v-if="player.id && session.sessionId" v-if="player.id && session.sessionId"
@ -135,6 +143,10 @@
<font-awesome-icon icon="chair" /> <font-awesome-icon icon="chair" />
Empty seat Empty seat
</li> </li>
<li @click="removePlayer">
<font-awesome-icon icon="times-circle" />
Remove
</li>
</template> </template>
<li <li
@click="claimSeat" @click="claimSeat"
@ -165,8 +177,13 @@
<span <span
class="icon" class="icon"
:style="{ :style="{
backgroundImage: `url(${reminder.image || backgroundImage: `url(${
require('../assets/icons/' + reminder.role + '.png')})` reminder.image && grimoire.isImageOptIn
? reminder.image
: require('../assets/icons/' +
(reminder.imageAlt || reminder.role) +
'.png')
})`
}" }"
></span> ></span>
<span class="text">{{ reminder.name }}</span> <span class="text">{{ reminder.name }}</span>
@ -226,10 +243,16 @@ export default {
isSwap: false isSwap: false
}; };
}, },
filters: {
handleEmojis: text => text.replace(/:([^: ]+?):/g, "").replace(/ •/g, "\n•")
},
methods: { methods: {
changePronouns() {
if (this.session.isSpectator && this.player.id !== this.session.playerId)
return;
const pronouns = prompt("Player pronouns", this.player.pronouns);
//Only update pronouns if not null (prompt was not cancelled)
if (pronouns !== null) {
this.updatePlayer("pronouns", pronouns, true);
}
},
toggleStatus() { toggleStatus() {
if (this.grimoire.isPublic) { if (this.grimoire.isPublic) {
if (!this.player.isDead) { if (!this.player.isDead) {
@ -258,7 +281,12 @@ export default {
this.updatePlayer("reminders", reminders, true); this.updatePlayer("reminders", reminders, true);
}, },
updatePlayer(property, value, closeMenu = false) { updatePlayer(property, value, closeMenu = false) {
if (this.session.isSpectator && property !== "reminders") return; if (
this.session.isSpectator &&
property !== "reminders" &&
property !== "pronouns"
)
return;
this.$store.commit("players/update", { this.$store.commit("players/update", {
player: this.player, player: this.player,
property, property,
@ -627,25 +655,72 @@ li.move:not(.from) .player .overlay svg.move {
/***** Player name *****/ /***** Player name *****/
.player > .name { .player > .name {
text-align: center; right: 10%;
display: flex;
justify-content: center;
font-size: 120%; font-size: 120%;
line-height: 120%; line-height: 120%;
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
width: 100%; width: 120%;
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
border: 3px solid black; border: 3px solid black;
border-radius: 10px; border-radius: 10px;
top: 5px; top: 5px;
box-shadow: 0 0 5px black; box-shadow: 0 0 5px black;
text-overflow: ellipsis;
overflow: hidden;
padding: 0 4px; padding: 0 4px;
svg {
top: 3px;
margin-right: 2px;
}
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
flex-grow: 1;
}
#townsquare:not(.spectator) &:hover, #townsquare:not(.spectator) &:hover,
&.active { &.active {
color: red; color: red;
} }
&:hover .pronouns {
opacity: 1;
color: white;
}
.pronouns {
display: flex;
position: absolute;
right: 110%;
max-width: 250px;
z-index: 25;
background: rgba(0, 0, 0, 0.5);
border-radius: 10px;
border: 3px solid black;
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5));
align-items: center;
pointer-events: none;
opacity: 0;
transition: opacity 200ms ease-in-out;
padding: 0 4px;
bottom: -3px;
&:before {
content: " ";
border: 10px solid transparent;
width: 0;
height: 0;
border-left-color: black;
position: absolute;
margin-left: 2px;
left: 100%;
}
}
} }
.player.dead > .name { .player.dead > .name {
@ -655,7 +730,7 @@ li.move:not(.from) .player .overlay svg.move {
/***** Player menu *****/ /***** Player menu *****/
.player > .menu { .player > .menu {
position: absolute; position: absolute;
left: 100%; left: 110%;
bottom: -5px; bottom: -5px;
text-align: left; text-align: left;
white-space: nowrap; white-space: nowrap;

View File

@ -4,8 +4,11 @@
class="icon" class="icon"
v-if="role.id" v-if="role.id"
:style="{ :style="{
backgroundImage: `url(${role.image || backgroundImage: `url(${
require('../assets/icons/' + role.id + '.png')})` role.image && grimoire.isImageOptIn
? role.image
: require('../assets/icons/' + (role.imageAlt || role.id) + '.png')
})`
}" }"
></span> ></span>
<span <span
@ -47,6 +50,8 @@
</template> </template>
<script> <script>
import { mapState } from "vuex";
export default { export default {
name: "Token", name: "Token",
props: { props: {
@ -55,6 +60,9 @@ export default {
default: () => ({}) default: () => ({})
} }
}, },
computed: {
...mapState(["grimoire"])
},
data() { data() {
return {}; return {};
}, },

View File

@ -4,8 +4,11 @@
class="edition" class="edition"
:class="['edition-' + edition.id]" :class="['edition-' + edition.id]"
:style="{ :style="{
backgroundImage: `url(${edition.logo || backgroundImage: `url(${
require('../assets/editions/' + edition.id + '.png')})` edition.logo && grimoire.isImageOptIn
? edition.logo
: require('../assets/editions/' + edition.id + '.png')
})`
}" }"
></li> ></li>
<li v-if="players.length - teams.traveler < 5"> <li v-if="players.length - teams.traveler < 5">

View File

@ -264,7 +264,7 @@ export default {
// open menu on the left // open menu on the left
.player > .menu { .player > .menu {
left: auto; left: auto;
right: 100%; right: 110%;
margin-right: 15px; margin-right: 15px;
&:before { &:before {
border-left-color: black; border-left-color: black;
@ -292,6 +292,16 @@ export default {
left: 100%; left: 100%;
} }
} }
.pronouns {
left: 110%;
right: auto;
&:before {
border-left-color: transparent;
border-right-color: black;
left: auto;
right: 100%;
}
}
} @else { } @else {
// second half of players // second half of players
z-index: $i - 1; z-index: $i - 1;

View File

@ -10,17 +10,14 @@
<em>{{ nominee.name }}</em <em>{{ nominee.name }}</em
>! >!
<br /> <br />
<template v-if="nominee.role.team !== 'traveler'"> <em class="blue">
<em class="blue"> {{ voters.length }} vote{{ voters.length !== 1 ? "s" : "" }}
{{ voters.length }} vote{{ voters.length !== 1 ? "s" : "" }} </em>
</em> in favor
in favor <em v-if="nominee.role.team !== 'traveler'">
<em>(majority is {{ Math.ceil(alive / 2) }})</em> (majority is {{ Math.ceil(alive / 2) }})
</template> </em>
<template v-else> <em v-else>(majority is {{ Math.ceil(players.length / 2) }})</em>
<em>{{ Math.ceil(players.length / 2) }} votes</em> required for a
<em>majority</em>.
</template>
<div v-if="session.isVoteInProgress || session.lockedVote > 1"> <div v-if="session.isVoteInProgress || session.lockedVote > 1">
<em class="blue" v-if="voters.length">{{ voters.join(", ") }} </em> <em class="blue" v-if="voters.length">{{ voters.join(", ") }} </em>
@ -170,7 +167,10 @@ export default {
...voters.slice(nomination + 1), ...voters.slice(nomination + 1),
...voters.slice(0, nomination + 1) ...voters.slice(0, nomination + 1)
]; ];
return reorder.slice(0, this.session.lockedVote - 1).filter(n => !!n); return (this.session.lockedVote
? reorder.slice(0, this.session.lockedVote - 1)
: reorder
).filter(n => !!n);
} }
}, },
data() { data() {

View File

@ -96,8 +96,8 @@ export default {
"https://gist.githubusercontent.com/bra1n/0337cc44c6fd2c44f7589256ed5486d2/raw/16be38fa3c01aaf49827303ac80577bdb52c0b25/penanceday.json" "https://gist.githubusercontent.com/bra1n/0337cc44c6fd2c44f7589256ed5486d2/raw/16be38fa3c01aaf49827303ac80577bdb52c0b25/penanceday.json"
], ],
[ [
"Catfishing 9.0", "Catfishing 11.1",
"https://gist.githubusercontent.com/bra1n/8a5ec41a7bbf945f6b7dfc1cef72b569/raw/fed370d55554e0d83e9d56023c230099f41d0660/catfishing.json" "https://gist.githubusercontent.com/bra1n/8a5ec41a7bbf945f6b7dfc1cef72b569/raw/a312ab93c2f302e0ef83c8b65a4e8e82760fda3a/catfishing.json"
], ],
[ [
"On Thin Ice (Teensyville)", "On Thin Ice (Teensyville)",
@ -163,19 +163,15 @@ export default {
if (metaIndex > -1) { if (metaIndex > -1) {
meta = roles.splice(metaIndex, 1).pop(); meta = roles.splice(metaIndex, 1).pop();
} }
const customRoles = roles.map(role => { this.$store.commit("setCustomRoles", roles);
role.id = role.id.toLocaleLowerCase().replace(/[^a-z0-9]/g, "");
return role;
});
this.$store.commit("setCustomRoles", customRoles);
this.$store.commit( this.$store.commit(
"setEdition", "setEdition",
Object.assign({}, meta, { id: "custom" }) Object.assign({}, meta, { id: "custom" })
); );
// check for fabled and set those too, if present // check for fabled and set those too, if present
if (customRoles.some(({ id }) => this.$store.state.fabled.has(id))) { if (roles.some(({ id }) => this.$store.state.fabled.has(id))) {
const fabled = []; const fabled = [];
customRoles.forEach(({ id }) => { roles.forEach(({ id }) => {
if (this.$store.state.fabled.has(id)) { if (this.$store.state.fabled.has(id)) {
fabled.push(this.$store.state.fabled.get(id)); fabled.push(this.$store.state.fabled.get(id));
} }

View File

@ -37,7 +37,9 @@ export default {
edition: this.edition.isOfficial edition: this.edition.isOfficial
? { id: this.edition.id } ? { id: this.edition.id }
: this.edition, : this.edition,
roles: this.edition.isOfficial ? "" : this.$store.getters.customRoles, roles: this.edition.isOfficial
? ""
: this.$store.getters.customRolesStripped,
fabled: this.players.fabled.map(({ id }) => id), fabled: this.players.fabled.map(({ id }) => id),
players: this.players.players.map(player => ({ players: this.players.players.map(player => ({
...player, ...player,

View File

@ -74,6 +74,11 @@ export default {
overflow-y: auto; overflow-y: auto;
} }
.roles & {
max-height: 100%;
max-width: 60%;
}
ul { ul {
list-style-type: none; list-style-type: none;
margin: 0; margin: 0;

View File

@ -25,7 +25,7 @@
> >
<span class="name"> <span class="name">
{{ role.name }} {{ role.name }}
<template v-if="role.players.length"> <span class="player" v-if="role.players.length">
<br /> <br />
<small <small
v-for="(player, index) in role.players" v-for="(player, index) in role.players"
@ -35,16 +35,24 @@
player.name + (role.players.length > index + 1 ? "," : "") player.name + (role.players.length > index + 1 ? "," : "")
}}</small }}</small
> >
</template> </span>
</span> </span>
<span <span
class="icon" class="icon"
v-if="role.id" v-if="role.id"
:style="{ :style="{
backgroundImage: `url(${role.image || backgroundImage: `url(${
require('../../assets/icons/' + role.id + '.png')})` role.image && grimoire.isImageOptIn
? role.image
: require('../../assets/icons/' +
(role.imageAlt || role.id) +
'.png')
})`
}" }"
></span> ></span>
<span class="reminder" v-if="role.firstNightReminder">
{{ role.firstNightReminder }}
</span>
</li> </li>
</ul> </ul>
<ul class="other"> <ul class="other">
@ -58,13 +66,18 @@
class="icon" class="icon"
v-if="role.id" v-if="role.id"
:style="{ :style="{
backgroundImage: `url(${role.image || backgroundImage: `url(${
require('../../assets/icons/' + role.id + '.png')})` role.image && grimoire.isImageOptIn
? role.image
: require('../../assets/icons/' +
(role.imageAlt || role.id) +
'.png')
})`
}" }"
></span> ></span>
<span class="name"> <span class="name">
{{ role.name }} {{ role.name }}
<template v-if="role.players.length"> <span class="player" v-if="role.players.length">
<br /> <br />
<small <small
v-for="(player, index) in role.players" v-for="(player, index) in role.players"
@ -74,7 +87,10 @@
player.name + (role.players.length > index + 1 ? "," : "") player.name + (role.players.length > index + 1 ? "," : "")
}}</small }}</small
> >
</template> </span>
</span>
<span class="reminder" v-if="role.otherNightReminder">
{{ role.otherNightReminder }}
</span> </span>
</li> </li>
</ul> </ul>
@ -99,16 +115,23 @@ export default {
{ {
id: "evil", id: "evil",
name: "Minion info", name: "Minion info",
firstNight: 2, firstNight: 3,
team: "minion", team: "minion",
players: this.players.filter(p => p.role.team === "minion") players: this.players.filter(p => p.role.team === "minion"),
firstNightReminder:
"• If more than one Minion, they all make eye contact with each other. " +
"• Show the “This is the Demon” card. Point to the Demon."
}, },
{ {
id: "evil", id: "evil",
name: "Demon info & bluffs", name: "Demon info & bluffs",
firstNight: 4, firstNight: 6,
team: "demon", team: "demon",
players: this.players.filter(p => p.role.team === "demon") players: this.players.filter(p => p.role.team === "demon"),
firstNightReminder:
"• Show the “These are your minions” card. Point to each Minion. " +
"• Show the “These characters are not in play” card. Show 3 character tokens of good " +
"characters not in play."
} }
); );
} }
@ -121,7 +144,7 @@ export default {
this.fabled this.fabled
.filter(({ firstNight }) => firstNight) .filter(({ firstNight }) => firstNight)
.forEach(fabled => { .forEach(fabled => {
rolesFirstNight.push(fabled); rolesFirstNight.push(Object.assign({ players: [] }, fabled));
}); });
rolesFirstNight.sort((a, b) => a.firstNight - b.firstNight); rolesFirstNight.sort((a, b) => a.firstNight - b.firstNight);
return rolesFirstNight; return rolesFirstNight;
@ -137,7 +160,7 @@ export default {
this.fabled this.fabled
.filter(({ otherNight }) => otherNight) .filter(({ otherNight }) => otherNight)
.forEach(fabled => { .forEach(fabled => {
rolesOtherNight.push(fabled); rolesOtherNight.push(Object.assign({ players: [] }, fabled));
}); });
rolesOtherNight.sort((a, b) => a.otherNight - b.otherNight); rolesOtherNight.sort((a, b) => a.otherNight - b.otherNight);
return rolesOtherNight; return rolesOtherNight;
@ -267,6 +290,26 @@ ul {
} }
} }
} }
.reminder {
position: fixed;
padding: 5px 10px;
left: 50%;
bottom: 10%;
width: 500px;
z-index: 25;
background: rgba(0, 0, 0, 0.75);
border-radius: 10px;
border: 3px solid black;
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5));
text-align: left;
pointer-events: none;
opacity: 0;
transition: opacity 200ms ease-in-out;
margin-left: -250px;
}
&:hover .reminder {
opacity: 1;
}
} }
&.legend { &.legend {
font-weight: bold; font-weight: bold;
@ -319,4 +362,9 @@ ul {
} }
} }
} }
/** hide players when town square is set to "public" **/
#townsquare.public ~ .night-reference .modal .player {
display: none;
}
</style> </style>

View File

@ -34,8 +34,13 @@
class="icon" class="icon"
v-if="role.id" v-if="role.id"
:style="{ :style="{
backgroundImage: `url(${role.image || backgroundImage: `url(${
require('../../assets/icons/' + role.id + '.png')})` role.image && grimoire.isImageOptIn
? role.image
: require('../../assets/icons/' +
(role.imageAlt || role.id) +
'.png')
})`
}" }"
></span> ></span>
<span class="ability">{{ role.ability }}</span> <span class="ability">{{ role.ability }}</span>
@ -80,7 +85,7 @@ export default {
}); });
return players; return players;
}, },
...mapState(["roles", "modals", "edition"]), ...mapState(["roles", "modals", "edition", "grimoire"]),
...mapState("players", ["players"]) ...mapState("players", ["players"])
}, },
methods: { methods: {
@ -230,4 +235,9 @@ ul {
} }
} }
} }
/** hide players when town square is set to "public" **/
#townsquare.public ~ .characters .modal .player {
display: none;
}
</style> </style>

View File

@ -15,8 +15,13 @@
<span <span
class="icon" class="icon"
:style="{ :style="{
backgroundImage: `url(${reminder.image || backgroundImage: `url(${
require('../../assets/icons/' + reminder.role + '.png')})` reminder.image && grimoire.isImageOptIn
? reminder.image
: require('../../assets/icons/' +
(reminder.imageAlt || reminder.role) +
'.png')
})`
}" }"
></span> ></span>
<span class="text">{{ reminder.name }}</span> <span class="text">{{ reminder.name }}</span>
@ -29,6 +34,18 @@
import Modal from "./Modal"; import Modal from "./Modal";
import { mapMutations, mapState } from "vuex"; import { mapMutations, mapState } from "vuex";
/**
* Helper function that maps a reminder name with a role-based object that provides necessary visual data.
* @param role The role for which the reminder should be generated
* @return {function(*): {image: string|string[]|string|*, role: *, name: *, imageAlt: string|*}}
*/
const mapReminder = ({ id, image, imageAlt }) => name => ({
role: id,
image,
imageAlt,
name
});
export default { export default {
components: { Modal }, components: { Modal },
props: ["playerIndex"], props: ["playerIndex"],
@ -39,61 +56,29 @@ export default {
this.$store.state.roles.forEach(role => { this.$store.state.roles.forEach(role => {
// add reminders from player roles // add reminders from player roles
if (players.some(p => p.role.id === role.id)) { if (players.some(p => p.role.id === role.id)) {
reminders = [ reminders = [...reminders, ...role.reminders.map(mapReminder(role))];
...reminders,
...role.reminders.map(name => ({
role: role.id,
image: role.image,
name
}))
];
} }
// add reminders from bluff/other roles // add reminders from bluff/other roles
else if (bluffs.some(bluff => bluff.id === role.id)) { else if (bluffs.some(bluff => bluff.id === role.id)) {
reminders = [ reminders = [...reminders, ...role.reminders.map(mapReminder(role))];
...reminders,
...role.reminders.map(name => ({
role: role.id,
image: role.image,
name
}))
];
} }
// add global reminders // add global reminders
if (role.remindersGlobal && role.remindersGlobal.length) { if (role.remindersGlobal && role.remindersGlobal.length) {
reminders = [ reminders = [
...reminders, ...reminders,
...role.remindersGlobal.map(name => ({ ...role.remindersGlobal.map(mapReminder(role))
role: role.id,
image: role.image,
name
}))
]; ];
} }
}); });
// add fabled reminders // add fabled reminders
this.$store.state.players.fabled.forEach(role => { this.$store.state.players.fabled.forEach(role => {
reminders = [ reminders = [...reminders, ...role.reminders.map(mapReminder(role))];
...reminders,
...role.reminders.map(name => ({
role: role.id,
image: role.image,
name
}))
];
}); });
// add out of script traveler reminders // add out of script traveler reminders
this.$store.state.otherTravelers.forEach(role => { this.$store.state.otherTravelers.forEach(role => {
if (players.some(p => p.role.id === role.id)) { if (players.some(p => p.role.id === role.id)) {
reminders = [ reminders = [...reminders, ...role.reminders.map(mapReminder(role))];
...reminders,
...role.reminders.map(name => ({
role: role.id,
image: role.image,
name
}))
];
} }
}); });
@ -102,7 +87,7 @@ export default {
reminders.push({ role: "custom", name: "Custom note" }); reminders.push({ role: "custom", name: "Custom note" });
return reminders; return reminders;
}, },
...mapState(["modals"]), ...mapState(["modals", "grimoire"]),
...mapState("players", ["players"]) ...mapState("players", ["players"])
}, },
methods: { methods: {

View File

@ -36,7 +36,7 @@
class="button" class="button"
:class="{ townsfolk: tab === 'editionRoles' }" :class="{ townsfolk: tab === 'editionRoles' }"
@click="tab = 'editionRoles'" @click="tab = 'editionRoles'"
>Edtition Roles</span >Edition Roles</span
> >
<span <span
class="button" class="button"

View File

@ -7,22 +7,38 @@
<h3>Select the characters for {{ nonTravelers }} players:</h3> <h3>Select the characters for {{ nonTravelers }} players:</h3>
<ul class="tokens" v-for="(teamRoles, team) in roleSelection" :key="team"> <ul class="tokens" v-for="(teamRoles, team) in roleSelection" :key="team">
<li class="count" :class="[team]"> <li class="count" :class="[team]">
{{ teamRoles.filter(role => role.selected).length }} / {{ teamRoles.reduce((a, { selected }) => a + selected, 0) }} /
{{ game[nonTravelers - 5][team] }} {{ game[nonTravelers - 5][team] }}
</li> </li>
<li <li
v-for="role in teamRoles" v-for="role in teamRoles"
:class="[role.team, role.selected ? 'selected' : '']" :class="[role.team, role.selected ? 'selected' : '']"
:key="role.id" :key="role.id"
@click="role.selected = !role.selected" @click="role.selected = role.selected ? 0 : 1"
> >
<Token :role="role" /> <Token :role="role" />
<div class="buttons" v-if="allowMultiple">
<font-awesome-icon
icon="minus-circle"
@click.stop="role.selected--"
/>
<span>{{ role.selected > 1 ? "x" + role.selected : "" }}</span>
<font-awesome-icon icon="plus-circle" @click.stop="role.selected++" />
</div>
</li> </li>
</ul> </ul>
<div class="warning" v-if="hasSelectedSetupRoles"> <div class="warning" v-if="hasSelectedSetupRoles">
Warning: there are characters selected that modify the game setup! The <font-awesome-icon icon="exclamation-triangle" />
randomizer does not account for these characters. <span>
Warning: there are characters selected that modify the game setup! The
randomizer does not account for these characters.
</span>
</div> </div>
<label class="multiple" :class="{ checked: allowMultiple }">
<font-awesome-icon :icon="allowMultiple ? 'check-square' : 'square'" />
<input type="checkbox" name="allow-multiple" v-model="allowMultiple" />
Allow duplicate characters
</label>
<div class="button-group"> <div class="button-group">
<div <div
class="button" class="button"
@ -58,13 +74,14 @@ export default {
data: function() { data: function() {
return { return {
roleSelection: {}, roleSelection: {},
game: gameJSON game: gameJSON,
allowMultiple: false
}; };
}, },
computed: { computed: {
selectedRoles: function() { selectedRoles: function() {
return Object.values(this.roleSelection) return Object.values(this.roleSelection)
.map(roles => roles.filter(role => role.selected).length) .map(roles => roles.reduce((a, { selected }) => a + selected, 0))
.reduce((a, b) => a + b, 0); .reduce((a, b) => a + b, 0);
}, },
hasSelectedSetupRoles: function() { hasSelectedSetupRoles: function() {
@ -84,7 +101,7 @@ export default {
this.$set(this.roleSelection, role.team, []); this.$set(this.roleSelection, role.team, []);
} }
this.roleSelection[role.team].push(role); this.roleSelection[role.team].push(role);
this.$set(role, "selected", false); this.$set(role, "selected", 0);
}); });
delete this.roleSelection["traveler"]; delete this.roleSelection["traveler"];
const playerCount = Math.max(5, this.nonTravelers); const playerCount = Math.max(5, this.nonTravelers);
@ -93,10 +110,10 @@ export default {
for (let x = 0; x < composition[team]; x++) { for (let x = 0; x < composition[team]; x++) {
if (this.roleSelection[team]) { if (this.roleSelection[team]) {
const available = this.roleSelection[team].filter( const available = this.roleSelection[team].filter(
role => role.selected !== true role => !role.selected
); );
if (available.length) { if (available.length) {
randomElement(available).selected = true; randomElement(available).selected = 1;
} }
} }
} }
@ -106,7 +123,12 @@ export default {
if (this.selectedRoles <= this.nonTravelers && this.selectedRoles) { if (this.selectedRoles <= this.nonTravelers && this.selectedRoles) {
// generate list of selected roles and randomize it // generate list of selected roles and randomize it
const roles = Object.values(this.roleSelection) const roles = Object.values(this.roleSelection)
.map(roles => roles.filter(role => role.selected)) .map(roles =>
roles
// duplicate roles selected more than once and filter unselected
.reduce((a, r) => [...a, ...Array(r.selected).fill(r)], [])
)
// flatten into a single array
.reduce((a, b) => [...a, ...b], []) .reduce((a, b) => [...a, ...b], [])
.map(a => [Math.random(), a]) .map(a => [Math.random(), a])
.sort((a, b) => a[0] - b[0]) .sort((a, b) => a[0] - b[0])
@ -146,12 +168,15 @@ ul.tokens {
padding-left: 5%; padding-left: 5%;
li { li {
border-radius: 50%; border-radius: 50%;
width: 6vw; width: 5vw;
margin: 1%; margin: 5px;
opacity: 0.5; opacity: 0.5;
transition: all 250ms; transition: all 250ms;
&.selected { &.selected {
opacity: 1; opacity: 1;
.buttons {
display: flex;
}
} }
&.townsfolk { &.townsfolk {
box-shadow: 0 0 10px $townsfolk, 0 0 10px #004cff; box-shadow: 0 0 10px $townsfolk, 0 0 10px #004cff;
@ -172,6 +197,27 @@ ul.tokens {
transform: scale(1.2); transform: scale(1.2);
z-index: 10; z-index: 10;
} }
.buttons {
display: none;
position: absolute;
top: 95%;
text-align: center;
width: 100%;
z-index: 30;
font-weight: bold;
filter: drop-shadow(0 0 5px rgba(0, 0, 0, 1));
span {
flex-grow: 1;
}
svg {
opacity: 0.25;
cursor: pointer;
&:hover {
opacity: 1;
color: red;
}
}
}
} }
.count { .count {
opacity: 1; opacity: 1;
@ -203,9 +249,51 @@ ul.tokens {
} }
} }
.roles .modal .warning { .roles .modal {
color: red; .multiple {
text-align: center; display: block;
margin: auto; text-align: center;
cursor: pointer;
&.checked,
&:hover {
color: red;
}
&.checked {
margin-top: 10px;
}
svg {
margin-right: 5px;
}
input {
display: none;
}
}
.warning {
color: red;
position: absolute;
bottom: 20px;
right: 20px;
z-index: 10;
svg {
font-size: 150%;
vertical-align: middle;
}
span {
display: none;
text-align: center;
position: absolute;
right: -20px;
bottom: 30px;
width: 420px;
background: rgba(0, 0, 0, 0.75);
padding: 5px;
border-radius: 10px;
border: 2px solid black;
}
&:hover span {
display: block;
}
}
} }
</style> </style>

View File

@ -13,7 +13,7 @@
"id": "angel", "id": "angel",
"firstNightReminder": "", "firstNightReminder": "",
"otherNightReminder": "", "otherNightReminder": "",
"reminders": ["Protect", "Punish"], "reminders": ["Protect", "Something Bad"],
"setup": false, "setup": false,
"name": "Angel", "name": "Angel",
"team": "fabled", "team": "fabled",
@ -33,7 +33,7 @@
"id": "hellslibrarian", "id": "hellslibrarian",
"firstNightReminder": "", "firstNightReminder": "",
"otherNightReminder": "", "otherNightReminder": "",
"reminders": ["Punish"], "reminders": ["Something Bad"],
"setup": false, "setup": false,
"name": "Hell's Librarian", "name": "Hell's Librarian",
"team": "fabled", "team": "fabled",

View File

@ -9,6 +9,7 @@ import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
const faIcons = [ const faIcons = [
"AddressCard", "AddressCard",
"BookOpen", "BookOpen",
"BookDead",
"BroadcastTower", "BroadcastTower",
"Chair", "Chair",
"CheckSquare", "CheckSquare",
@ -18,6 +19,7 @@ const faIcons = [
"Dice", "Dice",
"Dragon", "Dragon",
"ExchangeAlt", "ExchangeAlt",
"ExclamationTriangle",
"FileCode", "FileCode",
"FileUpload", "FileUpload",
"HandPaper", "HandPaper",
@ -43,6 +45,7 @@ const faIcons = [
"UserEdit", "UserEdit",
"UserFriends", "UserFriends",
"Users", "Users",
"VenusMars",
"VolumeUp", "VolumeUp",
"VolumeMute", "VolumeMute",
"VoteYea", "VoteYea",

View File

@ -4,7 +4,7 @@
"name": "Washerwoman", "name": "Washerwoman",
"edition": "tb", "edition": "tb",
"team": "townsfolk", "team": "townsfolk",
"firstNight": 20, "firstNight": 23,
"firstNightReminder": "Show the character token of a Townsfolk in play. Point to two players, one of which is that character.", "firstNightReminder": "Show the character token of a Townsfolk in play. Point to two players, one of which is that character.",
"otherNight": 0, "otherNight": 0,
"otherNightReminder": "", "otherNightReminder": "",
@ -18,7 +18,7 @@
"name": "Librarian", "name": "Librarian",
"edition": "tb", "edition": "tb",
"team": "townsfolk", "team": "townsfolk",
"firstNight": 21, "firstNight": 24,
"firstNightReminder": "Show the character token of an Outsider in play. Point to two players, one of which is that character.", "firstNightReminder": "Show the character token of an Outsider in play. Point to two players, one of which is that character.",
"otherNight": 0, "otherNight": 0,
"otherNightReminder": "", "otherNightReminder": "",
@ -32,7 +32,7 @@
"name": "Investigator", "name": "Investigator",
"edition": "tb", "edition": "tb",
"team": "townsfolk", "team": "townsfolk",
"firstNight": 22, "firstNight": 25,
"firstNightReminder": "Show the character token of a Minion in play. Point to two players, one of which is that character.", "firstNightReminder": "Show the character token of a Minion in play. Point to two players, one of which is that character.",
"otherNight": 0, "otherNight": 0,
"otherNightReminder": "", "otherNightReminder": "",
@ -46,7 +46,7 @@
"name": "Chef", "name": "Chef",
"edition": "tb", "edition": "tb",
"team": "townsfolk", "team": "townsfolk",
"firstNight": 23, "firstNight": 26,
"firstNightReminder": "Show the finger signal (0, 1, 2, \u2026) for the number of pairs of neighbouring evil players.", "firstNightReminder": "Show the finger signal (0, 1, 2, \u2026) for the number of pairs of neighbouring evil players.",
"otherNight": 0, "otherNight": 0,
"otherNightReminder": "", "otherNightReminder": "",
@ -59,9 +59,9 @@
"name": "Empath", "name": "Empath",
"edition": "tb", "edition": "tb",
"team": "townsfolk", "team": "townsfolk",
"firstNight": 24, "firstNight": 27,
"firstNightReminder": "Show the finger signal (0, 1, 2) for the number of evil alive neighbours of the Empath.", "firstNightReminder": "Show the finger signal (0, 1, 2) for the number of evil alive neighbours of the Empath.",
"otherNight": 43, "otherNight": 46,
"otherNightReminder": "Show the finger signal (0, 1, 2) for the number of evil neighbours.", "otherNightReminder": "Show the finger signal (0, 1, 2) for the number of evil neighbours.",
"reminders": [], "reminders": [],
"setup": false, "setup": false,
@ -72,9 +72,9 @@
"name": "Fortune Teller", "name": "Fortune Teller",
"edition": "tb", "edition": "tb",
"team": "townsfolk", "team": "townsfolk",
"firstNight": 25, "firstNight": 28,
"firstNightReminder": "The Fortune Teller points to two players. Give the head signal (nod yes, shake no) for whether one of those players is the Demon. ", "firstNightReminder": "The Fortune Teller points to two players. Give the head signal (nod yes, shake no) for whether one of those players is the Demon. ",
"otherNight": 44, "otherNight": 47,
"otherNightReminder": "The Fortune Teller points to two players. Show the head signal (nod 'yes', shake 'no') for whether one of those players is the Demon.", "otherNightReminder": "The Fortune Teller points to two players. Show the head signal (nod 'yes', shake 'no') for whether one of those players is the Demon.",
"reminders": ["Red herring"], "reminders": ["Red herring"],
"setup": false, "setup": false,
@ -87,7 +87,7 @@
"team": "townsfolk", "team": "townsfolk",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "firstNightReminder": "",
"otherNight": 46, "otherNight": 49,
"otherNightReminder": "If a player was executed today: Show that player\u2019s character token.", "otherNightReminder": "If a player was executed today: Show that player\u2019s character token.",
"reminders": ["Executed"], "reminders": ["Executed"],
"setup": false, "setup": false,
@ -100,7 +100,7 @@
"team": "townsfolk", "team": "townsfolk",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "firstNightReminder": "",
"otherNight": 10, "otherNight": 11,
"otherNightReminder": "The previously protected player is no longer protected. The Monk points to a player not themself. Mark that player 'Protected'.", "otherNightReminder": "The previously protected player is no longer protected. The Monk points to a player not themself. Mark that player 'Protected'.",
"reminders": ["Protected"], "reminders": ["Protected"],
"setup": false, "setup": false,
@ -113,7 +113,7 @@
"team": "townsfolk", "team": "townsfolk",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "firstNightReminder": "",
"otherNight": 41, "otherNight": 44,
"otherNightReminder": "If the Ravenkeeper died tonight: The Ravenkeeper points to a player. Show that player\u2019s character token.", "otherNightReminder": "If the Ravenkeeper died tonight: The Ravenkeeper points to a player. Show that player\u2019s character token.",
"reminders": [], "reminders": [],
"setup": false, "setup": false,
@ -176,9 +176,9 @@
"name": "Butler", "name": "Butler",
"edition": "tb", "edition": "tb",
"team": "outsider", "team": "outsider",
"firstNight": 26, "firstNight": 29,
"firstNightReminder": "The Butler points to a player. Mark that player as 'Master'.", "firstNightReminder": "The Butler points to a player. Mark that player as 'Master'.",
"otherNight": 45, "otherNight": 48,
"otherNightReminder": "The Butler points to a player. Mark that player as 'Master'.", "otherNightReminder": "The Butler points to a player. Mark that player as 'Master'.",
"reminders": ["Master"], "reminders": ["Master"],
"setup": false, "setup": false,
@ -229,9 +229,9 @@
"name": "Poisoner", "name": "Poisoner",
"edition": "tb", "edition": "tb",
"team": "minion", "team": "minion",
"firstNight": 9, "firstNight": 11,
"firstNightReminder": "The Poisoner points to a player. That player is poisoned.", "firstNightReminder": "The Poisoner points to a player. That player is poisoned.",
"otherNight": 5, "otherNight": 6,
"otherNightReminder": "The previously poisoned player is no longer poisoned. The Poisoner points to a player. That player is poisoned.", "otherNightReminder": "The previously poisoned player is no longer poisoned. The Poisoner points to a player. That player is poisoned.",
"reminders": ["Poisoned"], "reminders": ["Poisoned"],
"setup": false, "setup": false,
@ -242,9 +242,9 @@
"name": "Spy", "name": "Spy",
"edition": "tb", "edition": "tb",
"team": "minion", "team": "minion",
"firstNight": 34, "firstNight": 37,
"firstNightReminder": "Show the Grimoire to the Spy for as long as they need.", "firstNightReminder": "Show the Grimoire to the Spy for as long as they need.",
"otherNight": 56, "otherNight": 59,
"otherNightReminder": "Show the Grimoire to the Spy for as long as they need.", "otherNightReminder": "Show the Grimoire to the Spy for as long as they need.",
"reminders": [], "reminders": [],
"setup": false, "setup": false,
@ -257,7 +257,7 @@
"team": "minion", "team": "minion",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "firstNightReminder": "",
"otherNight": 15, "otherNight": 17,
"otherNightReminder": "If the Scarlet Woman became the Demon today: Show the 'You are' card, then the demon token.", "otherNightReminder": "If the Scarlet Woman became the Demon today: Show the 'You are' card, then the demon token.",
"reminders": ["Demon"], "reminders": ["Demon"],
"setup": false, "setup": false,
@ -283,38 +283,38 @@
"team": "demon", "team": "demon",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "firstNightReminder": "",
"otherNight": 19, "otherNight": 21,
"otherNightReminder": "The Imp points to a player. That player dies. If the Imp chose themselves: Replace the character of 1 alive minion with a spare Imp token. Show the 'You are' card, then the Imp token.", "otherNightReminder": "The Imp points to a player. That player dies. If the Imp chose themselves: Replace the character of 1 alive minion with a spare Imp token. Show the 'You are' card, then the Imp token.",
"reminders": ["Dead"], "reminders": ["Dead"],
"setup": false, "setup": false,
"ability": "Each night*, choose a player: they die. If you kill yourself this way, a Minion becomes the Imp." "ability": "Each night*, choose a player: they die. If you kill yourself this way, a Minion becomes the Imp."
}, },
{
"id": "thief",
"name": "Thief",
"edition": "tb",
"team": "traveler",
"firstNight": 0,
"firstNightReminder": "The Thief points to a player. Put the Thief's 'Negative vote' reminder by the chosen player's character token.",
"otherNight": 0,
"otherNightReminder": "The Thief points to a player. Put the Thief's 'Negative vote' reminder by the chosen player's character token.",
"reminders": ["Negative vote"],
"setup": false,
"ability": "Each night, choose a player (not yourself): their vote counts negatively tomorrow."
},
{ {
"id": "bureaucrat", "id": "bureaucrat",
"name": "Bureaucrat", "name": "Bureaucrat",
"edition": "tb", "edition": "tb",
"team": "traveler", "team": "traveler",
"firstNight": 0, "firstNight": 1,
"firstNightReminder": "The Bureaucrat points to a player. Put the Bureaucrat's '3 votes' reminder by the chosen player's character token.", "firstNightReminder": "The Bureaucrat points to a player. Put the Bureaucrat's '3 votes' reminder by the chosen player's character token.",
"otherNight": 0, "otherNight": 1,
"otherNightReminder": "The Bureaucrat points to a player. Put the Bureaucrat's '3 votes' reminder by the chosen player's character token.", "otherNightReminder": "The Bureaucrat points to a player. Put the Bureaucrat's '3 votes' reminder by the chosen player's character token.",
"reminders": ["3 votes"], "reminders": ["3 votes"],
"setup": false, "setup": false,
"ability": "Each night, choose a player (not yourself): their vote counts as 3 votes tomorrow." "ability": "Each night, choose a player (not yourself): their vote counts as 3 votes tomorrow."
}, },
{
"id": "thief",
"name": "Thief",
"edition": "tb",
"team": "traveler",
"firstNight": 1,
"firstNightReminder": "The Thief points to a player. Put the Thief's 'Negative vote' reminder by the chosen player's character token.",
"otherNight": 1,
"otherNightReminder": "The Thief points to a player. Put the Thief's 'Negative vote' reminder by the chosen player's character token.",
"reminders": ["Negative vote"],
"setup": false,
"ability": "Each night, choose a player (not yourself): their vote counts negatively tomorrow."
},
{ {
"id": "gunslinger", "id": "gunslinger",
"name": "Gunslinger", "name": "Gunslinger",
@ -359,9 +359,9 @@
"name": "Grandmother", "name": "Grandmother",
"edition": "bmr", "edition": "bmr",
"team": "townsfolk", "team": "townsfolk",
"firstNight": 27, "firstNight": 30,
"firstNightReminder": "Show the marked character token. Point to the marked player.", "firstNightReminder": "Show the marked character token. Point to the marked player.",
"otherNight": 39, "otherNight": 42,
"otherNightReminder": "If the Grandmother\u2019s grandchild was killed by the Demon tonight: The Grandmother dies.", "otherNightReminder": "If the Grandmother\u2019s grandchild was killed by the Demon tonight: The Grandmother dies.",
"reminders": ["Grandchild"], "reminders": ["Grandchild"],
"setup": false, "setup": false,
@ -372,9 +372,9 @@
"name": "Sailor", "name": "Sailor",
"edition": "bmr", "edition": "bmr",
"team": "townsfolk", "team": "townsfolk",
"firstNight": 5, "firstNight": 7,
"firstNightReminder": "The Sailor points to a living player. Either the Sailor, or the chosen player, is drunk.", "firstNightReminder": "The Sailor points to a living player. Either the Sailor, or the chosen player, is drunk.",
"otherNight": 2, "otherNight": 3,
"otherNightReminder": "The previously drunk player is no longer drunk. The Sailor points to a living player. Either the Sailor, or the chosen player, is drunk.", "otherNightReminder": "The previously drunk player is no longer drunk. The Sailor points to a living player. Either the Sailor, or the chosen player, is drunk.",
"reminders": ["Drunk"], "reminders": ["Drunk"],
"setup": false, "setup": false,
@ -385,9 +385,9 @@
"name": "Chambermaid", "name": "Chambermaid",
"edition": "bmr", "edition": "bmr",
"team": "townsfolk", "team": "townsfolk",
"firstNight": 37, "firstNight": 40,
"firstNightReminder": "The Chambermaid points to two players. Show the number signal (0, 1, 2, \u2026) for how many of those players wake tonight for their ability.", "firstNightReminder": "The Chambermaid points to two players. Show the number signal (0, 1, 2, \u2026) for how many of those players wake tonight for their ability.",
"otherNight": 59, "otherNight": 62,
"otherNightReminder": "The Chambermaid points to two players. Show the number signal (0, 1, 2, \u2026) for how many of those players wake tonight for their ability.", "otherNightReminder": "The Chambermaid points to two players. Show the number signal (0, 1, 2, \u2026) for how many of those players wake tonight for their ability.",
"reminders": [], "reminders": [],
"setup": false, "setup": false,
@ -400,7 +400,7 @@
"team": "townsfolk", "team": "townsfolk",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "firstNightReminder": "",
"otherNight": 17, "otherNight": 19,
"otherNightReminder": "The Exorcist points to a player, different from the previous night. If that player is the Demon: Wake the Demon. Show the Exorcist token. Point to the Exorcist. The Demon does not act tonight.", "otherNightReminder": "The Exorcist points to a player, different from the previous night. If that player is the Demon: Wake the Demon. Show the Exorcist token. Point to the Exorcist. The Demon does not act tonight.",
"reminders": ["Chosen"], "reminders": ["Chosen"],
"setup": false, "setup": false,
@ -413,7 +413,7 @@
"team": "townsfolk", "team": "townsfolk",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "firstNightReminder": "",
"otherNight": 6, "otherNight": 7,
"otherNightReminder": "The previously protected and drunk players lose those markers. The Innkeeper points to two players. Those players are protected. One is drunk.", "otherNightReminder": "The previously protected and drunk players lose those markers. The Innkeeper points to two players. Those players are protected. One is drunk.",
"reminders": ["Protected", "reminders": ["Protected",
"Drunk"], "Drunk"],
@ -427,7 +427,7 @@
"team": "townsfolk", "team": "townsfolk",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "firstNightReminder": "",
"otherNight": 8, "otherNight": 9,
"otherNightReminder": "The Gambler points to a player, and a character on their sheet. If incorrect, the Gambler dies.", "otherNightReminder": "The Gambler points to a player, and a character on their sheet. If incorrect, the Gambler dies.",
"reminders": ["Dead"], "reminders": ["Dead"],
"setup": false, "setup": false,
@ -440,7 +440,7 @@
"team": "townsfolk", "team": "townsfolk",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "firstNightReminder": "",
"otherNight": 36, "otherNight": 39,
"otherNightReminder": "If the Gossip\u2019s public statement was true: Choose a player not protected from dying tonight. That player dies.", "otherNightReminder": "If the Gossip\u2019s public statement was true: Choose a player not protected from dying tonight. That player dies.",
"reminders": ["Dead"], "reminders": ["Dead"],
"setup": false, "setup": false,
@ -451,9 +451,9 @@
"name": "Courtier", "name": "Courtier",
"edition": "bmr", "edition": "bmr",
"team": "townsfolk", "team": "townsfolk",
"firstNight": 11, "firstNight": 13,
"firstNightReminder": "The Courtier either shows a 'no' head signal, or points to a character on the sheet. If the Courtier used their ability : If that character is in play, that player is drunk.", "firstNightReminder": "The Courtier either shows a 'no' head signal, or points to a character on the sheet. If the Courtier used their ability: If that character is in play, that player is drunk.",
"otherNight": 7, "otherNight": 8,
"otherNightReminder": "Reduce the remaining number of days the marked player is poisoned. If the Courtier has not yet used their ability: The Courtier either shows a 'no' head signal, or points to a character on the sheet. If the Courtier used their ability: If that character is in play, that player is drunk.", "otherNightReminder": "Reduce the remaining number of days the marked player is poisoned. If the Courtier has not yet used their ability: The Courtier either shows a 'no' head signal, or points to a character on the sheet. If the Courtier used their ability: If that character is in play, that player is drunk.",
"reminders": ["Drunk 3", "reminders": ["Drunk 3",
"Drunk 2", "Drunk 2",
@ -469,7 +469,7 @@
"team": "townsfolk", "team": "townsfolk",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "firstNightReminder": "",
"otherNight": 35, "otherNight": 38,
"otherNightReminder": "If the Professor has not used their ability: The Professor either shakes their head no, or points to a player. If that player is a Townsfolk, they are now alive.", "otherNightReminder": "If the Professor has not used their ability: The Professor either shakes their head no, or points to a player. If that player is a Townsfolk, they are now alive.",
"reminders": ["Alive", "reminders": ["Alive",
"No ability"], "No ability"],
@ -535,7 +535,7 @@
"team": "outsider", "team": "outsider",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "The Tinker might die.", "firstNightReminder": "The Tinker might die.",
"otherNight": 37, "otherNight": 40,
"otherNightReminder": "The Tinker might die.", "otherNightReminder": "The Tinker might die.",
"reminders": ["Dead"], "reminders": ["Dead"],
"setup": false, "setup": false,
@ -548,7 +548,7 @@
"team": "outsider", "team": "outsider",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "firstNightReminder": "",
"otherNight": 38, "otherNight": 41,
"otherNightReminder": "If the Moonchild used their ability to target a player today: If that player is good, they die.", "otherNightReminder": "If the Moonchild used their ability to target a player today: If that player is good, they die.",
"reminders": ["Dead"], "reminders": ["Dead"],
"setup": false, "setup": false,
@ -572,9 +572,9 @@
"name": "Lunatic", "name": "Lunatic",
"edition": "bmr", "edition": "bmr",
"team": "outsider", "team": "outsider",
"firstNight": 3, "firstNight": 5,
"firstNightReminder": "If 7 or more players: Show the Lunatic a number of arbitrary 'Minions', players equal to the number of Minions in play. Show 3 character tokens of arbitrary good characters. If the token received by the Lunatic is a Demon that would wake tonight: Allow the Lunatic to do the Demon actions. Place their 'attack' markers. Wake the Demon. Show the Demon\u2019s real character token. Show them the Lunatic player. If the Lunatic attacked players: Show the real demon each marked player. Remove any Lunatic 'attack' markers.", "firstNightReminder": "If 7 or more players: Show the Lunatic a number of arbitrary 'Minions', players equal to the number of Minions in play. Show 3 character tokens of arbitrary good characters. If the token received by the Lunatic is a Demon that would wake tonight: Allow the Lunatic to do the Demon actions. Place their 'attack' markers. Wake the Demon. Show the Demon\u2019s real character token. Show them the Lunatic player. If the Lunatic attacked players: Show the real demon each marked player. Remove any Lunatic 'attack' markers.",
"otherNight": 16, "otherNight": 18,
"otherNightReminder": "Allow the Lunatic to do the actions of the Demon. Place their 'attack' markers. If the Lunatic selected players: Wake the Demon. Show the 'attack' marker, then point to each marked player. Remove any Lunatic 'attack' markers.", "otherNightReminder": "Allow the Lunatic to do the actions of the Demon. Place their 'attack' markers. If the Lunatic selected players: Wake the Demon. Show the 'attack' marker, then point to each marked player. Remove any Lunatic 'attack' markers.",
"reminders": ["Attack 1", "reminders": ["Attack 1",
"Attack 2", "Attack 2",
@ -587,9 +587,9 @@
"name": "Godfather", "name": "Godfather",
"edition": "bmr", "edition": "bmr",
"team": "minion", "team": "minion",
"firstNight": 13, "firstNight": 15,
"firstNightReminder": "Show each of the Outsider tokens in play.", "firstNightReminder": "Show each of the Outsider tokens in play.",
"otherNight": 31, "otherNight": 33,
"otherNightReminder": "If an Outsider died today: The Godfather points to a player. That player dies.", "otherNightReminder": "If an Outsider died today: The Godfather points to a player. That player dies.",
"reminders": ["Died today", "reminders": ["Died today",
"Dead"], "Dead"],
@ -601,9 +601,9 @@
"name": "Devil's Advocate", "name": "Devil's Advocate",
"edition": "bmr", "edition": "bmr",
"team": "minion", "team": "minion",
"firstNight": 14, "firstNight": 16,
"firstNightReminder": "The Devil\u2019s Advocate points to a living player. That player survives execution tomorrow.", "firstNightReminder": "The Devil\u2019s Advocate points to a living player. That player survives execution tomorrow.",
"otherNight": 11, "otherNight": 12,
"otherNightReminder": "The Devil\u2019s Advocate points to a living player, different from the previous night. That player survives execution tomorrow.", "otherNightReminder": "The Devil\u2019s Advocate points to a living player, different from the previous night. That player survives execution tomorrow.",
"reminders": ["Survives execution"], "reminders": ["Survives execution"],
"setup": false, "setup": false,
@ -616,7 +616,7 @@
"team": "minion", "team": "minion",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "firstNightReminder": "",
"otherNight": 30, "otherNight": 32,
"otherNightReminder": "If the Assassin has not yet used their ability: The Assassin either shows the 'no' head signal, or points to a player. That player dies.", "otherNightReminder": "If the Assassin has not yet used their ability: The Assassin either shows the 'no' head signal, or points to a player. That player dies.",
"reminders": ["Dead", "reminders": ["Dead",
"No ability"], "No ability"],
@ -643,7 +643,7 @@
"team": "demon", "team": "demon",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "firstNightReminder": "",
"otherNight": 20, "otherNight": 22,
"otherNightReminder": "If no-one died during the day: The Zombuul points to a player. That player dies.", "otherNightReminder": "If no-one died during the day: The Zombuul points to a player. That player dies.",
"reminders": ["Died today", "reminders": ["Died today",
"Dead"], "Dead"],
@ -655,9 +655,9 @@
"name": "Pukka", "name": "Pukka",
"edition": "bmr", "edition": "bmr",
"team": "demon", "team": "demon",
"firstNight": 18, "firstNight": 21,
"firstNightReminder": "The Pukka points to a player. That player is poisoned.", "firstNightReminder": "The Pukka points to a player. That player is poisoned.",
"otherNight": 21, "otherNight": 23,
"otherNightReminder": "The poisoned player dies. The Pukka points to a player. That player is poisoned.", "otherNightReminder": "The poisoned player dies. The Pukka points to a player. That player is poisoned.",
"reminders": ["Poisoned", "reminders": ["Poisoned",
"Dead"], "Dead"],
@ -671,7 +671,7 @@
"team": "demon", "team": "demon",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "firstNightReminder": "",
"otherNight": 23, "otherNight": 24,
"otherNightReminder": "One player that the Shabaloth chose the previous night might be resurrected. The Shabaloth points to two players. Those players die.", "otherNightReminder": "One player that the Shabaloth chose the previous night might be resurrected. The Shabaloth points to two players. Those players die.",
"reminders": ["Dead", "reminders": ["Dead",
"Alive"], "Alive"],
@ -685,7 +685,7 @@
"team": "demon", "team": "demon",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "firstNightReminder": "",
"otherNight": 24, "otherNight": 25,
"otherNightReminder": "If the Po chose no-one the previous night: The Po points to three players. Otherwise: The Po either shows the 'no' head signal , or points to a player. Chosen players die", "otherNightReminder": "If the Po chose no-one the previous night: The Po points to three players. Otherwise: The Po either shows the 'no' head signal , or points to a player. Chosen players die",
"reminders": ["Dead", "reminders": ["Dead",
"3 attacks"], "3 attacks"],
@ -697,7 +697,7 @@
"name": "Apprentice", "name": "Apprentice",
"edition": "bmr", "edition": "bmr",
"team": "traveler", "team": "traveler",
"firstNight": 0, "firstNight": 1,
"firstNightReminder": "Show the Apprentice the 'You are' card, then a Townsfolk or Minion token. In the Grimoire, replace the Apprentice token with that character token, and put the Apprentice's 'Is the Apprentice' reminder by that character token.", "firstNightReminder": "Show the Apprentice the 'You are' card, then a Townsfolk or Minion token. In the Grimoire, replace the Apprentice token with that character token, and put the Apprentice's 'Is the Apprentice' reminder by that character token.",
"otherNight": 0, "otherNight": 0,
"otherNightReminder": "", "otherNightReminder": "",
@ -763,7 +763,7 @@
"name": "Clockmaker", "name": "Clockmaker",
"edition": "snv", "edition": "snv",
"team": "townsfolk", "team": "townsfolk",
"firstNight": 28, "firstNight": 31,
"firstNightReminder": "Show the hand signal for the number (1, 2, 3, etc.) of places from Demon to closest Minion.", "firstNightReminder": "Show the hand signal for the number (1, 2, 3, etc.) of places from Demon to closest Minion.",
"otherNight": 0, "otherNight": 0,
"otherNightReminder": "", "otherNightReminder": "",
@ -776,9 +776,9 @@
"name": "Dreamer", "name": "Dreamer",
"edition": "snv", "edition": "snv",
"team": "townsfolk", "team": "townsfolk",
"firstNight": 29, "firstNight": 32,
"firstNightReminder": "The Dreamer points to a player. Show 1 good and 1 evil character token; one of these is correct.", "firstNightReminder": "The Dreamer points to a player. Show 1 good and 1 evil character token; one of these is correct.",
"otherNight": 47, "otherNight": 50,
"otherNightReminder": "The Dreamer points to a player. Show 1 good and 1 evil character token; one of these is correct.", "otherNightReminder": "The Dreamer points to a player. Show 1 good and 1 evil character token; one of these is correct.",
"reminders": [], "reminders": [],
"setup": false, "setup": false,
@ -789,9 +789,9 @@
"name": "Snake Charmer", "name": "Snake Charmer",
"edition": "snv", "edition": "snv",
"team": "townsfolk", "team": "townsfolk",
"firstNight": 12, "firstNight": 14,
"firstNightReminder": "The Snake Charmer points to a player. If that player is the Demon: swap the Demon and Snake Charmer character and alignments. Wake each player to inform them of their new role and alignment. The new Snake Charmer is poisoned.", "firstNightReminder": "The Snake Charmer points to a player. If that player is the Demon: swap the Demon and Snake Charmer character and alignments. Wake each player to inform them of their new role and alignment. The new Snake Charmer is poisoned.",
"otherNight": 9, "otherNight": 10,
"otherNightReminder": "The Snake Charmer points to a player. If that player is the Demon: swap the Demon and Snake Charmer character and alignments. Wake each player to inform them of their new role and alignment. The new Snake Charmer is poisoned.", "otherNightReminder": "The Snake Charmer points to a player. If that player is the Demon: swap the Demon and Snake Charmer character and alignments. Wake each player to inform them of their new role and alignment. The new Snake Charmer is poisoned.",
"reminders": ["Poisoned"], "reminders": ["Poisoned"],
"setup": false, "setup": false,
@ -802,9 +802,9 @@
"name": "Mathematician", "name": "Mathematician",
"edition": "snv", "edition": "snv",
"team": "townsfolk", "team": "townsfolk",
"firstNight": 36, "firstNight": 39,
"firstNightReminder": "Show the hand signal for the number (0, 1, 2, etc.) of players whose ability malfunctioned due to other abilities.", "firstNightReminder": "Show the hand signal for the number (0, 1, 2, etc.) of players whose ability malfunctioned due to other abilities.",
"otherNight": 58, "otherNight": 61,
"otherNightReminder": "Show the hand signal for the number (0, 1, 2, etc.) of players whose ability malfunctioned due to other abilities.", "otherNightReminder": "Show the hand signal for the number (0, 1, 2, etc.) of players whose ability malfunctioned due to other abilities.",
"reminders": ["Abnormal"], "reminders": ["Abnormal"],
"setup": false, "setup": false,
@ -817,7 +817,7 @@
"team": "townsfolk", "team": "townsfolk",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "Place the 'Demon not voted' marker.", "firstNightReminder": "Place the 'Demon not voted' marker.",
"otherNight": 48, "otherNight": 51,
"otherNightReminder": "Nod 'yes' or shake head 'no' for whether the Demon voted today. Place the 'Demon not voted' marker (remove 'Demon voted', if any).", "otherNightReminder": "Nod 'yes' or shake head 'no' for whether the Demon voted today. Place the 'Demon not voted' marker (remove 'Demon voted', if any).",
"reminders": ["Demon voted", "reminders": ["Demon voted",
"Demon not voted"], "Demon not voted"],
@ -831,7 +831,7 @@
"team": "townsfolk", "team": "townsfolk",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "Place the 'Minions not nominated' marker.", "firstNightReminder": "Place the 'Minions not nominated' marker.",
"otherNight": 49, "otherNight": 52,
"otherNightReminder": "Nod 'yes' or shake head 'no' for whether a Minion nominated today. Place the 'Minion not nominated' marker (remove 'Minion nominated', if any).", "otherNightReminder": "Nod 'yes' or shake head 'no' for whether a Minion nominated today. Place the 'Minion not nominated' marker (remove 'Minion nominated', if any).",
"reminders": ["Minions not nominated", "reminders": ["Minions not nominated",
"Minion nominated"], "Minion nominated"],
@ -845,7 +845,7 @@
"team": "townsfolk", "team": "townsfolk",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "firstNightReminder": "",
"otherNight": 50, "otherNight": 53,
"otherNightReminder": "Show the hand signal for the number (0, 1, 2, etc.) of dead evil players.", "otherNightReminder": "Show the hand signal for the number (0, 1, 2, etc.) of dead evil players.",
"reminders": [], "reminders": [],
"setup": false, "setup": false,
@ -869,9 +869,9 @@
"name": "Seamstress", "name": "Seamstress",
"edition": "snv", "edition": "snv",
"team": "townsfolk", "team": "townsfolk",
"firstNight": 30, "firstNight": 33,
"firstNightReminder": "The Seamstress either shows a 'no' head signal, or points to two other players. If the Seamstress chose players , nod 'yes' or shake 'no' for whether they are of same alignment.", "firstNightReminder": "The Seamstress either shows a 'no' head signal, or points to two other players. If the Seamstress chose players , nod 'yes' or shake 'no' for whether they are of same alignment.",
"otherNight": 51, "otherNight": 54,
"otherNightReminder": "If the Seamstress has not yet used their ability: the Seamstress either shows a 'no' head signal, or points to two other players. If the Seamstress chose players , nod 'yes' or shake 'no' for whether they are of same alignment.", "otherNightReminder": "If the Seamstress has not yet used their ability: the Seamstress either shows a 'no' head signal, or points to two other players. If the Seamstress chose players , nod 'yes' or shake 'no' for whether they are of same alignment.",
"reminders": ["No ability"], "reminders": ["No ability"],
"setup": false, "setup": false,
@ -887,7 +887,7 @@
"otherNight": 1, "otherNight": 1,
"otherNightReminder": "If the Philosopher has not used their ability: the Philosopher either shows a 'no' head signal, or points to a good character on their sheet. If they chose a character: Swap the out-of-play character token with the Philosopher token. Or, if the character is in play, place the drunk marker by that player and the Not the Philosopher token by the Philosopher.", "otherNightReminder": "If the Philosopher has not used their ability: the Philosopher either shows a 'no' head signal, or points to a good character on their sheet. If they chose a character: Swap the out-of-play character token with the Philosopher token. Or, if the character is in play, place the drunk marker by that player and the Not the Philosopher token by the Philosopher.",
"reminders": ["Drunk", "reminders": ["Drunk",
"Is not the Philosopher"], "Is the Philosopher"],
"setup": false, "setup": false,
"ability": "Once per game, at night, choose a good character: gain that ability. If this character is in play, they are drunk." "ability": "Once per game, at night, choose a good character: gain that ability. If this character is in play, they are drunk."
}, },
@ -911,7 +911,7 @@
"team": "townsfolk", "team": "townsfolk",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "firstNightReminder": "",
"otherNight": 52, "otherNight": 55,
"otherNightReminder": "If today was the Juggler\u2019s first day: Show the hand signal for the number (0, 1, 2, etc.) of 'Correct' markers. Remove markers.", "otherNightReminder": "If today was the Juggler\u2019s first day: Show the hand signal for the number (0, 1, 2, etc.) of 'Correct' markers. Remove markers.",
"reminders": ["Correct"], "reminders": ["Correct"],
"setup": false, "setup": false,
@ -924,7 +924,7 @@
"team": "townsfolk", "team": "townsfolk",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "firstNightReminder": "",
"otherNight": 34, "otherNight": 37,
"otherNightReminder": "If the Sage was killed by a Demon: Point to two players, one of which is that Demon.", "otherNightReminder": "If the Sage was killed by a Demon: Point to two players, one of which is that Demon.",
"reminders": [], "reminders": [],
"setup": false, "setup": false,
@ -950,7 +950,7 @@
"team": "outsider", "team": "outsider",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "firstNightReminder": "",
"otherNight": 33, "otherNight": 36,
"otherNightReminder": "Choose a player that is drunk.", "otherNightReminder": "Choose a player that is drunk.",
"reminders": ["Drunk"], "reminders": ["Drunk"],
"setup": false, "setup": false,
@ -963,7 +963,7 @@
"team": "outsider", "team": "outsider",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "firstNightReminder": "",
"otherNight": 32, "otherNight": 35,
"otherNightReminder": "If the Barber died today: Wake the Demon. Show the 'This character selected you' card, then Barber token. The Demon either shows a 'no' head signal, or points to 2 players. If they chose players: Swap the character tokens. Wake each player. Show 'You are', then their new character token.", "otherNightReminder": "If the Barber died today: Wake the Demon. Show the 'This character selected you' card, then Barber token. The Demon either shows a 'no' head signal, or points to 2 players. If they chose players: Swap the character tokens. Wake each player. Show 'You are', then their new character token.",
"reminders": ["Haircuts tonight"], "reminders": ["Haircuts tonight"],
"setup": false, "setup": false,
@ -987,7 +987,7 @@
"name": "Evil Twin", "name": "Evil Twin",
"edition": "snv", "edition": "snv",
"team": "minion", "team": "minion",
"firstNight": 15, "firstNight": 17,
"firstNightReminder": "Wake the Evil Twin and their twin. Confirm that they have acknowledged each other. Point to the Evil Twin. Show their Evil Twin token to the twin player. Point to the twin. Show their character token to the Evil Twin player.", "firstNightReminder": "Wake the Evil Twin and their twin. Confirm that they have acknowledged each other. Point to the Evil Twin. Show their Evil Twin token to the twin player. Point to the twin. Show their character token to the Evil Twin player.",
"otherNight": 0, "otherNight": 0,
"otherNightReminder": "", "otherNightReminder": "",
@ -1000,9 +1000,9 @@
"name": "Witch", "name": "Witch",
"edition": "snv", "edition": "snv",
"team": "minion", "team": "minion",
"firstNight": 16, "firstNight": 18,
"firstNightReminder": "The Witch points to a player. If that player nominates tomorrow they die immediately.", "firstNightReminder": "The Witch points to a player. If that player nominates tomorrow they die immediately.",
"otherNight": 12, "otherNight": 13,
"otherNightReminder": "If there are 4 or more players alive: The Witch points to a player. If that player nominates tomorrow they die immediately.", "otherNightReminder": "If there are 4 or more players alive: The Witch points to a player. If that player nominates tomorrow they die immediately.",
"reminders": ["Cursed"], "reminders": ["Cursed"],
"setup": false, "setup": false,
@ -1013,9 +1013,9 @@
"name": "Cerenovus", "name": "Cerenovus",
"edition": "snv", "edition": "snv",
"team": "minion", "team": "minion",
"firstNight": 17, "firstNight": 19,
"firstNightReminder": "The Cerenovus points to a player, then to a character on their sheet. Wake that player. Show the 'This character selected you' card, then the Cerenovus token. Show the selected character token. If the player is not mad about being that character tomorrow, they can be executed.", "firstNightReminder": "The Cerenovus points to a player, then to a character on their sheet. Wake that player. Show the 'This character selected you' card, then the Cerenovus token. Show the selected character token. If the player is not mad about being that character tomorrow, they can be executed.",
"otherNight": 13, "otherNight": 14,
"otherNightReminder": "The Cerenovus points to a player, then to a character on their sheet. Wake that player. Show the 'This character selected you' card, then the Cerenovus token. Show the selected character token. If the player is not mad about being that character tomorrow, they can be executed.", "otherNightReminder": "The Cerenovus points to a player, then to a character on their sheet. Wake that player. Show the 'This character selected you' card, then the Cerenovus token. Show the selected character token. If the player is not mad about being that character tomorrow, they can be executed.",
"reminders": ["Mad"], "reminders": ["Mad"],
"setup": false, "setup": false,
@ -1028,7 +1028,7 @@
"team": "minion", "team": "minion",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "firstNightReminder": "",
"otherNight": 14, "otherNight": 15,
"otherNightReminder": "The Pit-Hag points to a player and a character on the sheet. If this character is not in play, wake that player and show them the 'You are' card and the relevant character token. If the character is in play, nothing happens.", "otherNightReminder": "The Pit-Hag points to a player and a character on the sheet. If this character is not in play, wake that player and show them the 'You are' card and the relevant character token. If the character is in play, nothing happens.",
"reminders": [], "reminders": [],
"setup": false, "setup": false,
@ -1041,7 +1041,7 @@
"team": "demon", "team": "demon",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "firstNightReminder": "",
"otherNight": 25, "otherNight": 26,
"otherNightReminder": "The Fang Gu points to a player. That player dies. Or, if that player was an Outsider and there are no other Fang Gu in play: The Fang Gu dies instead of the chosen player. The chosen player is now an evil Fang Gu. Wake the new Fang Gu. Show the 'You are' card, then the Fang Gu token. Show the 'You are' card, then the thumb-down 'evil' hand sign.", "otherNightReminder": "The Fang Gu points to a player. That player dies. Or, if that player was an Outsider and there are no other Fang Gu in play: The Fang Gu dies instead of the chosen player. The chosen player is now an evil Fang Gu. Wake the new Fang Gu. Show the 'You are' card, then the Fang Gu token. Show the 'You are' card, then the thumb-down 'evil' hand sign.",
"reminders": ["Dead"], "reminders": ["Dead"],
"setup": true, "setup": true,
@ -1054,7 +1054,7 @@
"team": "demon", "team": "demon",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "firstNightReminder": "",
"otherNight": 28, "otherNight": 29,
"otherNightReminder": "The Vigormortis points to a player. That player dies. If a Minion, they keep their ability and one of their Townsfolk neighbours is poisoned.", "otherNightReminder": "The Vigormortis points to a player. That player dies. If a Minion, they keep their ability and one of their Townsfolk neighbours is poisoned.",
"reminders": ["Dead", "reminders": ["Dead",
"Has ability", "Has ability",
@ -1069,7 +1069,7 @@
"team": "demon", "team": "demon",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "firstNightReminder": "",
"otherNight": 26, "otherNight": 27,
"otherNightReminder": "The No Dashii points to a player. That player dies.", "otherNightReminder": "The No Dashii points to a player. That player dies.",
"reminders": ["Dead", "reminders": ["Dead",
"Poisoned"], "Poisoned"],
@ -1083,7 +1083,7 @@
"team": "demon", "team": "demon",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "firstNightReminder": "",
"otherNight": 27, "otherNight": 28,
"otherNightReminder": "The Vortox points to a player. That player dies.", "otherNightReminder": "The Vortox points to a player. That player dies.",
"reminders": ["Dead"], "reminders": ["Dead"],
"setup": false, "setup": false,
@ -1094,10 +1094,10 @@
"name": "Barista", "name": "Barista",
"edition": "snv", "edition": "snv",
"team": "traveler", "team": "traveler",
"firstNight": 0, "firstNight": 1,
"firstNightReminder": "", "firstNightReminder": "Choose a player, wake them and tell them which Barista power is affecting them. Treat them accordingly (sober/healthy/true info or activate their ability twice).",
"otherNight": 0, "otherNight": 1,
"otherNightReminder": "", "otherNightReminder": "Choose a player, wake them and tell them which Barista power is affecting them. Treat them accordingly (sober/healthy/true info or activate their ability twice).",
"reminders": ["Sober & Healthy", "reminders": ["Sober & Healthy",
"Ability twice"], "Ability twice"],
"setup": false, "setup": false,
@ -1110,7 +1110,7 @@
"team": "traveler", "team": "traveler",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "firstNightReminder": "",
"otherNight": 0, "otherNight": 1,
"otherNightReminder": "The Harlot points at any player. Then, put the Harlot to sleep. Wake the chosen player, show them the 'This character selected you' token, then the Harlot token. That player either nods their head yes or shakes their head no. If they nodded their head yes, wake the Harlot and show them the chosen player's character token. Then, you may decide that both players die.", "otherNightReminder": "The Harlot points at any player. Then, put the Harlot to sleep. Wake the chosen player, show them the 'This character selected you' token, then the Harlot token. That player either nods their head yes or shakes their head no. If they nodded their head yes, wake the Harlot and show them the chosen player's character token. Then, you may decide that both players die.",
"reminders": ["Dead"], "reminders": ["Dead"],
"setup": false, "setup": false,
@ -1136,7 +1136,7 @@
"team": "traveler", "team": "traveler",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "firstNightReminder": "",
"otherNight": 0, "otherNight": 1,
"otherNightReminder": "The Bone Collector either shakes their head no or points at any dead player. If they pointed at any dead player, put the Bone Collector's 'Has Ability' reminder by the chosen player's character token. (They may need to be woken tonight to use it.)", "otherNightReminder": "The Bone Collector either shakes their head no or points at any dead player. If they pointed at any dead player, put the Bone Collector's 'Has Ability' reminder by the chosen player's character token. (They may need to be woken tonight to use it.)",
"reminders": ["No ability", "reminders": ["No ability",
"Has ability"], "Has ability"],
@ -1161,9 +1161,9 @@
"name": "Bounty Hunter", "name": "Bounty Hunter",
"edition": "", "edition": "",
"team": "townsfolk", "team": "townsfolk",
"firstNight": 32, "firstNight": 35,
"firstNightReminder": "Point to 1 evil player. Wake the townsfolk who is evil and show them the 'You are' card and the thumbs down evil sign.", "firstNightReminder": "Point to 1 evil player. Wake the townsfolk who is evil and show them the 'You are' card and the thumbs down evil sign.",
"otherNight": 54, "otherNight": 57,
"otherNightReminder": "If the known evil player has died, point to another evil player. ", "otherNightReminder": "If the known evil player has died, point to another evil player. ",
"reminders": ["Known"], "reminders": ["Known"],
"setup": true, "setup": true,
@ -1174,49 +1174,49 @@
"name": "Pixie", "name": "Pixie",
"edition": "", "edition": "",
"team": "townsfolk", "team": "townsfolk",
"firstNight": 19, "firstNight": 22,
"firstNightReminder": "Show the Pixie 1 in-play Townsfolk role.", "firstNightReminder": "Show the Pixie 1 in-play Townsfolk character token.",
"otherNight": 0, "otherNight": 0,
"otherNightReminder": "", "otherNightReminder": "If the Pixie has been mad they were the relevant character and that player has died, treat the Pixie as if they have the relevant character ability.",
"reminders": ["Mad", "reminders": ["Mad",
"Has ability"], "Has ability"],
"setup": false, "setup": false,
"ability": "You start knowing 1 in-play Townsfolk. If you were mad that you were this character, you gain their ability when they die." "ability": "You start knowing 1 in-play Townsfolk. If you were mad that you were this character, you gain their ability when they die."
}, },
{
"id": "preacher",
"name": "Preacher",
"edition": "",
"team": "townsfolk",
"firstNight": 7,
"firstNightReminder": "The Preacher chooses a player. If a Minion is chosen, wake the Minion and show the 'This character selected you' card and then the Preacher token.",
"otherNight": 4,
"otherNightReminder": "The Preacher chooses a player. If a Minion is chosen, wake the Minion and show the 'This character selected you' card and then the Preacher token.",
"reminders": ["At a sermon"],
"setup": false,
"ability": "Each night, choose a player: a Minion, if chosen, learns this. All chosen Minions have no ability."
},
{ {
"id": "general", "id": "general",
"name": "General", "name": "General",
"edition": "", "edition": "",
"team": "townsfolk", "team": "townsfolk",
"firstNight": 35, "firstNight": 38,
"firstNightReminder": "Show the General thumbs up for good winning, thumbs down for evil winning or thumb to the side for neither.", "firstNightReminder": "Show the General thumbs up for good winning, thumbs down for evil winning or thumb to the side for neither.",
"otherNight": 57, "otherNight": 60,
"otherNightReminder": "Show the General thumbs up for good winning, thumbs down for evil winning or thumb to the side for neither.", "otherNightReminder": "Show the General thumbs up for good winning, thumbs down for evil winning or thumb to the side for neither.",
"reminders": [], "reminders": [],
"setup": false, "setup": false,
"ability": "Each night, you learn which alignment the Storyteller believes is winning: good, evil, or neither." "ability": "Each night, you learn which alignment the Storyteller believes is winning: good, evil, or neither."
}, },
{
"id": "preacher",
"name": "Preacher",
"edition": "",
"team": "townsfolk",
"firstNight": 9,
"firstNightReminder": "The Preacher chooses a player. If a Minion is chosen, wake the Minion and show the 'This character selected you' card and then the Preacher token.",
"otherNight": 5,
"otherNightReminder": "The Preacher chooses a player. If a Minion is chosen, wake the Minion and show the 'This character selected you' card and then the Preacher token.",
"reminders": ["At a sermon"],
"setup": false,
"ability": "Each night, choose a player: a Minion, if chosen, learns this. All chosen Minions have no ability."
},
{ {
"id": "balloonist", "id": "balloonist",
"name": "Balloonist", "name": "Balloonist",
"edition": "", "edition": "",
"team": "townsfolk", "team": "townsfolk",
"firstNight": 31, "firstNight": 34,
"firstNightReminder": "Choose a character type. Point to a player whose character is of that type. Place the Balloonist's Seen reminder next to that character.", "firstNightReminder": "Choose a character type. Point to a player whose character is of that type. Place the Balloonist's Seen reminder next to that character.",
"otherNight": 53, "otherNight": 56,
"otherNightReminder": "Choose a character type that does not yet have a Seen reminder next to a character of that type. Point to a player whose character is of that type, if there are any. Place the Balloonist's Seen reminder next to that character.", "otherNightReminder": "Choose a character type that does not yet have a Seen reminder next to a character of that type. Point to a player whose character is of that type, if there are any. Place the Balloonist's Seen reminder next to that character.",
"reminders": ["Seen Townsfolk", "reminders": ["Seen Townsfolk",
"Seen Outsider", "Seen Outsider",
@ -1231,9 +1231,9 @@
"name": "Cult Leader", "name": "Cult Leader",
"edition": "", "edition": "",
"team": "townsfolk", "team": "townsfolk",
"firstNight": 33, "firstNight": 36,
"firstNightReminder": "If the cult leader changed alignment, show them the thumbs up good signal of the thumbs down evil signal accordingly.", "firstNightReminder": "If the cult leader changed alignment, show them the thumbs up good signal of the thumbs down evil signal accordingly.",
"otherNight": 55, "otherNight": 58,
"otherNightReminder": "If the cult leader changed alignment, show them the thumbs up good signal of the thumbs down evil signal accordingly.", "otherNightReminder": "If the cult leader changed alignment, show them the thumbs up good signal of the thumbs down evil signal accordingly.",
"reminders": [], "reminders": [],
"setup": false, "setup": false,
@ -1246,7 +1246,7 @@
"team": "townsfolk", "team": "townsfolk",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "firstNightReminder": "",
"otherNight": 18, "otherNight": 20,
"otherNightReminder": "The Lycanthrope points to a living player: if good, they die and no one else can die tonight.", "otherNightReminder": "The Lycanthrope points to a living player: if good, they die and no one else can die tonight.",
"reminders": ["Dead"], "reminders": ["Dead"],
"setup": false, "setup": false,
@ -1257,9 +1257,9 @@
"name": "Amnesiac", "name": "Amnesiac",
"edition": "", "edition": "",
"team": "townsfolk", "team": "townsfolk",
"firstNight": 6, "firstNight": 8,
"firstNightReminder": "Decide the Amnesiac's entire ability. If the Amnesiac's ability causes them to wake tonight: Wake the Amnesiac and run their ability.", "firstNightReminder": "Decide the Amnesiac's entire ability. If the Amnesiac's ability causes them to wake tonight: Wake the Amnesiac and run their ability.",
"otherNight": 3, "otherNight": 4,
"otherNightReminder": "If the Amnesiac's ability causes them to wake tonight: Wake the Amnesiac and run their ability.", "otherNightReminder": "If the Amnesiac's ability causes them to wake tonight: Wake the Amnesiac and run their ability.",
"reminders": ["?"], "reminders": ["?"],
"setup": false, "setup": false,
@ -1278,6 +1278,19 @@
"setup": false, "setup": false,
"ability": "Once per game, during the day, visit the Storyteller for some advice to help you win." "ability": "Once per game, during the day, visit the Storyteller for some advice to help you win."
}, },
{
"id": "poppygrower",
"name": "Poppy Grower",
"edition": "",
"team": "townsfolk",
"firstNight": 2,
"firstNightReminder": "Do not inform the Demon/Minions who each other are",
"otherNight": 2,
"otherNightReminder": "If the Poppy Grower has died, show the Minions/Demon who each other are.",
"reminders": ["Evil wakes"],
"setup": false,
"ability": "Minions & Demons do not know each other. If you die, they learn who each other are that night."
},
{ {
"id": "cannibal", "id": "cannibal",
"name": "Cannibal", "name": "Cannibal",
@ -1292,6 +1305,19 @@
"setup": false, "setup": false,
"ability": "You have the ability of the recently killed executee. If they are evil, you are poisoned until a good player dies by execution." "ability": "You have the ability of the recently killed executee. If they are evil, you are poisoned until a good player dies by execution."
}, },
{
"id": "snitch",
"name": "Snitch",
"edition": "",
"team": "outsider",
"firstNight": 4,
"firstNightReminder": "After Minion info wake each Minion and show them three not-in-play character tokens. These may be the same or different to each other and the ones shown to the Demon.",
"otherNight": 0,
"otherNightReminder": "",
"reminders": [],
"setup": false,
"ability": "Minions start knowing 3 not-in-play characters."
},
{ {
"id": "acrobat", "id": "acrobat",
"name": "Acrobat", "name": "Acrobat",
@ -1299,7 +1325,7 @@
"team": "outsider", "team": "outsider",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "If a good living neighbour is drunk or poisoned, the Acrobat player dies.", "firstNightReminder": "If a good living neighbour is drunk or poisoned, the Acrobat player dies.",
"otherNight": 22, "otherNight": 34,
"otherNightReminder": "If a good living neighbour is drunk or poisoned, the Acrobat player dies.", "otherNightReminder": "If a good living neighbour is drunk or poisoned, the Acrobat player dies.",
"reminders": ["Dead"], "reminders": ["Dead"],
"setup": false, "setup": false,
@ -1323,12 +1349,12 @@
"name": "Widow", "name": "Widow",
"edition": "", "edition": "",
"team": "minion", "team": "minion",
"firstNight": 10, "firstNight": 12,
"firstNightReminder": "Show the Grimoire to the Widow for as long as they need. The Widow points to a player. That player is poisoned. Wake a good player. Show the 'These characters are in play' card, then the Widow character token.", "firstNightReminder": "Show the Grimoire to the Widow for as long as they need. The Widow points to a player. That player is poisoned. Wake a good player. Show the 'These characters are in play' card, then the Widow character token.",
"otherNight": 0, "otherNight": 0,
"otherNightReminder": "", "otherNightReminder": "",
"reminders": ["Poisoned", "reminders": ["Poisoned"],
"Knows"], "remindersGlobal": ["Knows"],
"setup": false, "setup": false,
"ability": "On your 1st night, look at the Grimoire and choose a player: they are poisoned. 1 good player knows a Widow is in play." "ability": "On your 1st night, look at the Grimoire and choose a player: they are poisoned. 1 good player knows a Widow is in play."
}, },
@ -1345,27 +1371,55 @@
"setup": false, "setup": false,
"ability": "If you publicly claim to be the Goblin when nominated & are executed that day, your team wins." "ability": "If you publicly claim to be the Goblin when nominated & are executed that day, your team wins."
}, },
{
"id": "mephit",
"name": "Mephit",
"edition": "",
"team": "minion",
"firstNight": 20,
"firstNightReminder": "Show the Mephit their secret word.",
"otherNight": 16,
"otherNightReminder": "Wake the 1st good player that said the Mephit's secret word and show them the 'You are' card and the thumbs down evil signal.",
"reminders": ["Turns evil",
"No ability"],
"setup": false,
"ability": "You start knowing a secret word. The 1st good player to say this word becomes evil that night."
},
{ {
"id": "lilmonsta", "id": "lilmonsta",
"name": "Lil Monsta", "name": "Lil Monsta",
"edition": "", "edition": "",
"team": "demon", "team": "demon",
"firstNight": 8, "firstNight": 10,
"firstNightReminder": "", "firstNightReminder": "Wake all Minions together, allow them to vote by pointing at who they want to babysit Lil' Monsta.",
"otherNight": 29, "otherNight": 31,
"otherNightReminder": "Choose a player, that player dies.", "otherNightReminder": "Wake all Minions together, allow them to vote by pointing at who they want to babysit Lil' Monsta. Choose a player, that player dies.",
"reminders": [], "reminders": [],
"remindersGlobal": ["Is the Demon", "remindersGlobal": ["Is the Demon",
"Dead"], "Dead"],
"setup": true, "setup": true,
"ability": "Each night, Minions choose who babysits Lil Monsta's token & \"is the Demon\". A player dies each night*. [+1 Minion]" "ability": "Each night, Minions choose who babysits Lil Monsta's token & \"is the Demon\". A player dies each night*. [+1 Minion]"
}, },
{
"id": "legion",
"name": "Legion",
"edition": "",
"team": "demon",
"firstNight": 0,
"firstNightReminder": "",
"otherNight": 30,
"otherNightReminder": "Choose a player, that player dies.",
"reminders": ["Dead",
"About to die"],
"setup": true,
"ability": "Each night*, a player might die. Executions fail if only evil voted. You register as a Minion too. [Most players are Legion]"
},
{ {
"id": "leviathan", "id": "leviathan",
"name": "Leviathan", "name": "Leviathan",
"edition": "", "edition": "",
"team": "demon", "team": "demon",
"firstNight": 39, "firstNight": 42,
"firstNightReminder": "Place the Leviathan 'Day 1' marker. Announce 'The Leviathan is in play; this is Day 1.'", "firstNightReminder": "Place the Leviathan 'Day 1' marker. Announce 'The Leviathan is in play; this is Day 1.'",
"otherNight": 0, "otherNight": 0,
"otherNightReminder": "Place the next Leviathan 'Day n' marker, where 'n' is the next day number. Announce 'The Leviathan is in play; this is Day n.'.", "otherNightReminder": "Place the next Leviathan 'Day n' marker, where 'n' is the next day number. Announce 'The Leviathan is in play; this is Day n.'.",
@ -1378,5 +1432,4 @@
"setup": false, "setup": false,
"ability": "If more than 1 good player is executed, you win. All players know you are in play. After day 5, evil wins." "ability": "If more than 1 good player is executed, you win. All players know you are in play. After day 5, evil wins."
} }
] ]

View File

@ -10,12 +10,14 @@ import fabledJSON from "../fabled.json";
Vue.use(Vuex); Vue.use(Vuex);
// global data maps
const editionJSONbyId = new Map( const editionJSONbyId = new Map(
editionJSON.map(edition => [edition.id, edition]) editionJSON.map(edition => [edition.id, edition])
); );
const rolesJSONbyId = new Map(rolesJSON.map(role => [role.id, role])); const rolesJSONbyId = new Map(rolesJSON.map(role => [role.id, role]));
const fabled = new Map(fabledJSON.map(role => [role.id, role])); const fabled = new Map(fabledJSON.map(role => [role.id, role]));
// helper functions
const getRolesByEdition = (edition = editionJSON[0]) => { const getRolesByEdition = (edition = editionJSON[0]) => {
return new Map( return new Map(
rolesJSON rolesJSON
@ -38,11 +40,24 @@ const getTravelersNotInEdition = (edition = editionJSON[0]) => {
); );
}; };
const set = key => ({ grimoire }, val) => {
grimoire[key] = val;
};
const toggle = key => ({ grimoire }, val) => {
if (val === true || val === false) {
grimoire[key] = val;
} else {
grimoire[key] = !grimoire[key];
}
};
// base definition for custom roles // base definition for custom roles
const imageBase =
"https://raw.githubusercontent.com/bra1n/townsquare/main/src/assets/icons/";
const customRole = { const customRole = {
id: "",
name: "",
image: "", image: "",
ability: "",
edition: "custom", edition: "custom",
firstNight: 0, firstNight: 0,
firstNightReminder: "", firstNightReminder: "",
@ -67,6 +82,7 @@ export default new Vuex.Store({
isPublic: true, isPublic: true,
isMenuOpen: false, isMenuOpen: false,
isMuted: false, isMuted: false,
isImageOptIn: false,
zoom: 0, zoom: 0,
background: "" background: ""
}, },
@ -88,27 +104,31 @@ export default new Vuex.Store({
}, },
getters: { getters: {
/** /**
* Return all custom roles, with default values stripped. * Return all custom roles, with default values and non-essential data stripped.
* Role object keys will be replaced with a numerical index to conserve bandwidth.
* @param roles * @param roles
* @returns {[]} * @returns {[]}
*/ */
customRoles: ({ roles }) => { customRolesStripped: ({ roles }) => {
const customRoles = []; const customRoles = [];
const customKeys = Object.keys(customRole);
const strippedProps = [
"firstNightReminder",
"otherNightReminder",
"isCustom"
];
roles.forEach(role => { roles.forEach(role => {
if (!role.isCustom) { if (!role.isCustom) {
customRoles.push({ id: role.id }); customRoles.push({ id: role.id });
} else { } else {
const strippedRole = {}; const strippedRole = {};
for (let prop in role) { for (let prop in role) {
const value = role[prop]; if (strippedProps.includes(prop)) {
if (
prop === "image" &&
value.toLocaleLowerCase().includes(imageBase)
) {
continue; continue;
} }
if (prop !== "isCustom" && value !== customRole[prop]) { const value = role[prop];
strippedRole[prop] = value; if (customKeys.includes(prop) && value !== customRole[prop]) {
strippedRole[customKeys.indexOf(prop)] = value;
} }
} }
customRoles.push(strippedRole); customRoles.push(strippedRole);
@ -119,38 +139,14 @@ export default new Vuex.Store({
rolesJSONbyId: () => rolesJSONbyId rolesJSONbyId: () => rolesJSONbyId
}, },
mutations: { mutations: {
toggleMenu({ grimoire }) { setZoom: set("zoom"),
grimoire.isMenuOpen = !grimoire.isMenuOpen; setBackground: set("background"),
}, toggleMuted: toggle("isMuted"),
toggleGrimoire({ grimoire }, isPublic) { toggleMenu: toggle("isMenuOpen"),
if (isPublic === true || isPublic === false) { toggleNightOrder: toggle("isNightOrder"),
grimoire.isPublic = isPublic; toggleNight: toggle("isNight"),
} else { toggleGrimoire: toggle("isPublic"),
grimoire.isPublic = !grimoire.isPublic; toggleImageOptIn: toggle("isImageOptIn"),
}
document.title = `Blood on the Clocktower ${
grimoire.isPublic ? "Town Square" : "Grimoire"
}`;
},
toggleNight({ grimoire }, isNight) {
if (isNight === true || isNight === false) {
grimoire.isNight = isNight;
} else {
grimoire.isNight = !grimoire.isNight;
}
},
toggleNightOrder({ grimoire }) {
grimoire.isNightOrder = !grimoire.isNightOrder;
},
setZoom({ grimoire }, zoom) {
grimoire.zoom = zoom;
},
setBackground({ grimoire }, background) {
grimoire.background = background;
},
setIsMuted({ grimoire }, isMuted) {
grimoire.isMuted = isMuted;
},
toggleModal({ modals }, name) { toggleModal({ modals }, name) {
if (name) { if (name) {
modals[name] = !modals[name]; modals[name] = !modals[name];
@ -168,6 +164,26 @@ export default new Vuex.Store({
setCustomRoles(state, roles) { setCustomRoles(state, roles) {
state.roles = new Map( state.roles = new Map(
roles roles
// replace numerical role object keys with matching key names
.map(role => {
if (role[0]) {
const customKeys = Object.keys(customRole);
const mappedRole = {};
for (let prop in role) {
if (customKeys[prop]) {
mappedRole[customKeys[prop]] = role[prop];
}
}
return mappedRole;
} else {
return role;
}
})
// clean up role.id
.map(role => {
role.id = role.id.toLocaleLowerCase().replace(/[^a-z0-9]/g, "");
return role;
})
// map existing roles to base definition or pre-populate custom roles to ensure all properties // map existing roles to base definition or pre-populate custom roles to ensure all properties
.map( .map(
role => role =>
@ -175,16 +191,16 @@ export default new Vuex.Store({
state.roles.get(role.id) || state.roles.get(role.id) ||
Object.assign({}, customRole, role) Object.assign({}, customRole, role)
) )
// default empty icons to good / evil / traveler // default empty icons and placeholders
.map(role => { .map(role => {
if (rolesJSONbyId.get(role.id)) return role; if (rolesJSONbyId.get(role.id)) return role;
if (role.team === "townsfolk" || role.team === "outsider") { role.imageAlt = // map team to generic icon
role.image = role.image || imageBase + "good.png"; {
} else if (role.team === "demon" || role.team === "minion") { townsfolk: "good",
role.image = role.image || imageBase + "evil.png"; outsider: "outsider",
} else { minion: "minion",
role.image = role.image || imageBase + "custom.png"; demon: "evil"
} }[role.team] || "custom";
return role; return role;
}) })
// filter out roles that don't match an existing role and also don't have name/ability/team // filter out roles that don't match an existing role and also don't have name/ability/team

View File

@ -4,7 +4,8 @@ const NEWPLAYER = {
role: {}, role: {},
reminders: [], reminders: [],
isVoteless: false, isVoteless: false,
isDead: false isDead: false,
pronouns: ""
}; };
const state = () => ({ const state = () => ({
@ -79,10 +80,11 @@ const actions = {
return player; return player;
}); });
} else { } else {
players = state.players.map(({ name, id }) => ({ players = state.players.map(({ name, id, pronouns }) => ({
...NEWPLAYER, ...NEWPLAYER,
name, name,
id id,
pronouns
})); }));
commit("setFabled", { fabled: [] }); commit("setFabled", { fabled: [] });
} }
@ -100,6 +102,14 @@ const mutations = {
set(state, players = []) { set(state, players = []) {
state.players = players; state.players = players;
}, },
/**
The update mutation also has a property for isFromSockets
this property can be addded to payload object for any mutations
then can be used to prevent infinite loops when a property is
able to be set from multiple different session on websockets.
An example of this is in the sendPlayerPronouns and _updatePlayerPronouns
in socket.js.
*/
update(state, { player, property, value }) { update(state, { player, property, value }) {
const index = state.players.indexOf(player); const index = state.players.indexOf(player);
if (index >= 0) { if (index >= 0) {

View File

@ -1,8 +1,3 @@
// helper functions
const set = key => (state, val) => {
state[key] = val;
};
/** /**
* Handle a vote request. * Handle a vote request.
* If the vote is from a seat that is already locked, ignore it. * If the vote is from a seat that is already locked, ignore it.
@ -37,6 +32,11 @@ const getters = {};
const actions = {}; const actions = {};
// mutations helper functions
const set = key => (state, val) => {
state[key] = val;
};
const mutations = { const mutations = {
setPlayerId: set("playerId"), setPlayerId: set("playerId"),
setSpectator: set("isSpectator"), setSpectator: set("isSpectator"),

View File

@ -1,16 +1,25 @@
module.exports = store => { module.exports = store => {
const updatePagetitle = isPublic =>
(document.title = `Blood on the Clocktower ${
isPublic ? "Town Square" : "Grimoire"
}`);
// initialize data // initialize data
if (localStorage.getItem("background")) { if (localStorage.getItem("background")) {
store.commit("setBackground", localStorage.background); store.commit("setBackground", localStorage.background);
} }
if (localStorage.getItem("muted")) { if (localStorage.getItem("muted")) {
store.commit("setIsMuted", true); store.commit("toggleMuted", true);
}
if (localStorage.getItem("imageOptIn")) {
store.commit("toggleImageOptIn", true);
} }
if (localStorage.getItem("zoom")) { if (localStorage.getItem("zoom")) {
store.commit("setZoom", parseFloat(localStorage.getItem("zoom"))); store.commit("setZoom", parseFloat(localStorage.getItem("zoom")));
} }
if (localStorage.isPublic !== undefined) { if (localStorage.getItem("isGrimoire")) {
store.commit("toggleGrimoire", JSON.parse(localStorage.isPublic)); store.commit("toggleGrimoire", false);
updatePagetitle(false);
} }
if (localStorage.roles !== undefined) { if (localStorage.roles !== undefined) {
store.commit("setCustomRoles", JSON.parse(localStorage.roles)); store.commit("setCustomRoles", JSON.parse(localStorage.roles));
@ -61,10 +70,12 @@ module.exports = store => {
store.subscribe(({ type, payload }, state) => { store.subscribe(({ type, payload }, state) => {
switch (type) { switch (type) {
case "toggleGrimoire": case "toggleGrimoire":
localStorage.setItem( if (!state.grimoire.isPublic) {
"isPublic", localStorage.setItem("isGrimoire", 1);
JSON.stringify(state.grimoire.isPublic) } else {
); localStorage.removeItem("isGrimoire");
}
updatePagetitle(state.grimoire.isPublic);
break; break;
case "setBackground": case "setBackground":
if (payload) { if (payload) {
@ -73,13 +84,20 @@ module.exports = store => {
localStorage.removeItem("background"); localStorage.removeItem("background");
} }
break; break;
case "setIsMuted": case "toggleMuted":
if (payload) { if (state.grimoire.isMuted) {
localStorage.setItem("muted", 1); localStorage.setItem("muted", 1);
} else { } else {
localStorage.removeItem("muted"); localStorage.removeItem("muted");
} }
break; break;
case "toggleImageOptIn":
if (state.grimoire.isImageOptIn) {
localStorage.setItem("imageOptIn", 1);
} else {
localStorage.removeItem("imageOptIn");
}
break;
case "setZoom": case "setZoom":
if (payload !== 0) { if (payload !== 0) {
localStorage.setItem("zoom", payload); localStorage.setItem("zoom", payload);
@ -97,10 +115,7 @@ module.exports = store => {
if (!payload.length) { if (!payload.length) {
localStorage.removeItem("roles"); localStorage.removeItem("roles");
} else { } else {
localStorage.setItem( localStorage.setItem("roles", JSON.stringify(payload));
"roles",
JSON.stringify(store.getters.customRoles)
);
} }
break; break;
case "players/setBluff": case "players/setBluff":

View File

@ -62,13 +62,33 @@ class LiveSession {
} }
} }
/**
* Send a message directly to a single playerId, if provided.
* Otherwise broadcast it.
* @param playerId player ID or "host", optional
* @param command
* @param params
* @private
*/
_sendDirect(playerId, command, params) {
if (playerId) {
this._send("direct", { [playerId]: [command, params] });
} else {
this._send(command, params);
}
}
/** /**
* Open event handler for socket. * Open event handler for socket.
* @private * @private
*/ */
_onOpen() { _onOpen() {
if (this._isSpectator) { if (this._isSpectator) {
this._send("req", "gs"); this._sendDirect(
"host",
"getGamestate",
this._store.state.session.playerId
);
} else { } else {
this.sendGamestate(); this.sendGamestate();
} }
@ -80,12 +100,13 @@ class LiveSession {
* @private * @private
*/ */
_ping() { _ping() {
this._handlePing();
this._send("ping", [ this._send("ping", [
this._isSpectator, this._isSpectator
this._store.state.session.playerId, ? this._store.state.session.playerId
: Object.keys(this._players).length,
"latency" "latency"
]); ]);
this._handlePing();
clearTimeout(this._pingTimer); clearTimeout(this._pingTimer);
this._pingTimer = setTimeout(this._ping.bind(this), this._pingInterval); this._pingTimer = setTimeout(this._ping.bind(this), this._pingInterval);
} }
@ -103,10 +124,8 @@ class LiveSession {
console.log("unsupported socket message", data); console.log("unsupported socket message", data);
} }
switch (command) { switch (command) {
case "req": case "getGamestate":
if (params === "gs") { this.sendGamestate(params);
this.sendGamestate();
}
break; break;
case "edition": case "edition":
this._updateEdition(params); this._updateEdition(params);
@ -145,6 +164,10 @@ class LiveSession {
if (!this._isSpectator) return; if (!this._isSpectator) return;
this._store.commit("players/move", params); this._store.commit("players/move", params);
break; break;
case "remove":
if (!this._isSpectator) return;
this._store.commit("players/remove", params);
break;
case "isNight": case "isNight":
if (!this._isSpectator) return; if (!this._isSpectator) return;
this._store.commit("toggleNight", params); this._store.commit("toggleNight", params);
@ -170,6 +193,9 @@ class LiveSession {
case "bye": case "bye":
this._handleBye(params); this._handleBye(params);
break; break;
case "pronouns":
this._updatePlayerPronouns(params);
break;
} }
} }
@ -204,7 +230,9 @@ class LiveSession {
this._store.commit("session/setReconnecting", false); this._store.commit("session/setReconnecting", false);
clearTimeout(this._reconnectTimer); clearTimeout(this._reconnectTimer);
if (this._socket) { if (this._socket) {
this._send("bye", this._store.state.session.playerId); if (this._isSpectator) {
this._sendDirect("host", "bye", this._store.state.session.playerId);
}
this._socket.close(1000); this._socket.close(1000);
this._socket = null; this._socket = null;
} }
@ -213,26 +241,31 @@ class LiveSession {
/** /**
* Publish the current gamestate. * Publish the current gamestate.
* Optional param to reduce traffic. (send only player data) * Optional param to reduce traffic. (send only player data)
* @param playerId
* @param isLightweight * @param isLightweight
*/ */
sendGamestate(isLightweight = false) { sendGamestate(playerId = "", isLightweight = false) {
if (this._isSpectator) return; if (this._isSpectator) return;
this._gamestate = this._store.state.players.players.map(player => ({ this._gamestate = this._store.state.players.players.map(player => ({
name: player.name, name: player.name,
id: player.id, id: player.id,
isDead: player.isDead, isDead: player.isDead,
isVoteless: player.isVoteless, isVoteless: player.isVoteless,
pronouns: player.pronouns,
...(player.role && player.role.team === "traveler" ...(player.role && player.role.team === "traveler"
? { roleId: player.role.id } ? { roleId: player.role.id }
: {}) : {})
})); }));
if (isLightweight) { if (isLightweight) {
this._send("gs", { gamestate: this._gamestate, isLightweight }); this._sendDirect(playerId, "gs", {
gamestate: this._gamestate,
isLightweight
});
} else { } else {
const { session, grimoire } = this._store.state; const { session, grimoire } = this._store.state;
const { fabled } = this._store.state.players; const { fabled } = this._store.state.players;
this.sendEdition(); this.sendEdition(playerId);
this._send("gs", { this._sendDirect(playerId, "gs", {
gamestate: this._gamestate, gamestate: this._gamestate,
isNight: grimoire.isNight, isNight: grimoire.isNight,
nomination: session.nomination, nomination: session.nomination,
@ -279,7 +312,7 @@ class LiveSession {
const player = players[x]; const player = players[x];
const { roleId } = state; const { roleId } = state;
// update relevant properties // update relevant properties
["name", "id", "isDead", "isVoteless"].forEach(property => { ["name", "id", "isDead", "isVoteless", "pronouns"].forEach(property => {
const value = state[property]; const value = state[property];
if (player[property] !== value) { if (player[property] !== value) {
this._store.commit("players/update", { player, property, value }); this._store.commit("players/update", { player, property, value });
@ -322,18 +355,17 @@ class LiveSession {
/** /**
* Publish an edition update. ST only * Publish an edition update. ST only
* @param playerId
*/ */
sendEdition() { sendEdition(playerId = "") {
if (this._isSpectator) return; if (this._isSpectator) return;
const { edition } = this._store.state; const { edition } = this._store.state;
let roles; let roles;
if (!edition.isOfficial) { if (!edition.isOfficial) {
roles = Array.from(this._store.state.roles.keys()); roles = this._store.getters.customRolesStripped;
} }
this._send("edition", { this._sendDirect(playerId, "edition", {
edition: edition.isOfficial edition: edition.isOfficial ? { id: edition.id } : edition,
? { id: edition.id }
: Object.assign({}, edition, { logo: "" }),
...(roles ? { roles } : {}) ...(roles ? { roles } : {})
}); });
} }
@ -348,13 +380,10 @@ class LiveSession {
if (!this._isSpectator) return; if (!this._isSpectator) return;
this._store.commit("setEdition", edition); this._store.commit("setEdition", edition);
if (roles) { if (roles) {
this._store.commit( this._store.commit("setCustomRoles", roles);
"setCustomRoles",
roles.map(id => ({ id }))
);
if (this._store.state.roles.size !== roles.length) { if (this._store.state.roles.size !== roles.length) {
const missing = []; const missing = [];
roles.forEach(id => { roles.forEach(({ id }) => {
if (!this._store.state.roles.get(id)) { if (!this._store.state.roles.get(id)) {
missing.push(id); missing.push(id);
} }
@ -461,42 +490,73 @@ class LiveSession {
} }
/** /**
* Handle a ping message by another player / storyteller * Publish a player pronouns update
* @param isSpectator * @param player
* @param playerId * @param value
* @param timestamp * @param isFromSockets
*/
sendPlayerPronouns({ player, value, isFromSockets }) {
//send pronoun only for the seated player or storyteller
//Do not re-send pronoun data for an update that was recieved from the sockets layer
if (
isFromSockets ||
(this._isSpectator && this._store.state.session.playerId !== player.id)
)
return;
const index = this._store.state.players.players.indexOf(player);
this._send("pronouns", [index, value]);
}
/**
* Update a pronouns based on incoming data.
* @param index
* @param value
* @private * @private
*/ */
_handlePing([isSpectator, playerId, latency] = []) { _updatePlayerPronouns([index, value]) {
const now = new Date().getTime(); const player = this._store.state.players.players[index];
// remove players that haven't sent a ping in twice the timespan
for (let player in this._players) { this._store.commit("players/update", {
if (now - this._players[player] > this._pingInterval * 2) { player,
delete this._players[player]; property: "pronouns",
delete this._pings[player]; value,
} isFromSockets: true
}
// remove claimed seats from players that are no longer connected
this._store.state.players.players.forEach(player => {
if (!this._isSpectator && player.id && !this._players[player.id]) {
this._store.commit("players/update", {
player,
property: "id",
value: ""
});
}
}); });
// store new player data }
if (playerId) {
this._players[playerId] = now; /**
const ping = parseInt(latency, 10); * Handle a ping message by another player / storyteller
if (ping && ping > 0 && ping < 30 * 1000) { * @param playerIdOrCount
if (this._isSpectator && !isSpectator) { * @param latency
// ping to ST * @private
this._store.commit("session/setPing", ping); */
} else if (!this._isSpectator) { _handlePing([playerIdOrCount = 0, latency] = []) {
const now = new Date().getTime();
if (!this._isSpectator) {
// remove players that haven't sent a ping in twice the timespan
for (let player in this._players) {
if (now - this._players[player] > this._pingInterval * 2) {
delete this._players[player];
delete this._pings[player];
}
}
// remove claimed seats from players that are no longer connected
this._store.state.players.players.forEach(player => {
if (player.id && !this._players[player.id]) {
this._store.commit("players/update", {
player,
property: "id",
value: ""
});
}
});
// store new player data
if (playerIdOrCount) {
this._players[playerIdOrCount] = now;
const ping = parseInt(latency, 10);
if (ping && ping > 0 && ping < 30 * 1000) {
// ping to Players // ping to Players
this._pings[playerId] = ping; this._pings[playerIdOrCount] = ping;
const pings = Object.values(this._pings); const pings = Object.values(this._pings);
this._store.commit( this._store.commit(
"session/setPing", "session/setPing",
@ -504,19 +564,26 @@ class LiveSession {
); );
} }
} }
} else if (latency) {
// ping to ST
this._store.commit("session/setPing", parseInt(latency, 10));
}
// update player count
if (!this._isSpectator || playerIdOrCount) {
this._store.commit(
"session/setPlayerCount",
this._isSpectator ? playerIdOrCount : Object.keys(this._players).length
);
} }
this._store.commit(
"session/setPlayerCount",
Object.keys(this._players).length
);
} }
/** /**
* Handle a player leaving the sessions * Handle a player leaving the sessions. ST only
* @param playerId * @param playerId
* @private * @private
*/ */
_handleBye(playerId) { _handleBye(playerId) {
if (this._isSpectator) return;
delete this._players[playerId]; delete this._players[playerId];
this._store.commit( this._store.commit(
"session/setPlayerCount", "session/setPlayerCount",
@ -721,6 +788,15 @@ class LiveSession {
if (this._isSpectator) return; if (this._isSpectator) return;
this._send("move", payload); this._send("move", payload);
} }
/**
* Remove a player. ST only
* @param payload
*/
removePlayer(payload) {
if (this._isSpectator) return;
this._send("remove", payload);
}
} }
export default store => { export default store => {
@ -728,11 +804,11 @@ export default store => {
const session = new LiveSession(store); const session = new LiveSession(store);
// listen to mutations // listen to mutations
store.subscribe(({ type, payload }) => { store.subscribe(({ type, payload }, state) => {
switch (type) { switch (type) {
case "session/setSessionId": case "session/setSessionId":
if (payload) { if (state.session.sessionId) {
session.connect(payload); session.connect(state.session.sessionId);
} else { } else {
window.location.hash = ""; window.location.hash = "";
session.disconnect(); session.disconnect();
@ -779,14 +855,20 @@ export default store => {
case "players/move": case "players/move":
session.movePlayer(payload); session.movePlayer(payload);
break; break;
case "players/remove":
session.removePlayer(payload);
break;
case "players/set": case "players/set":
case "players/clear": case "players/clear":
case "players/remove":
case "players/add": case "players/add":
session.sendGamestate(true); session.sendGamestate("", true);
break; break;
case "players/update": case "players/update":
session.sendPlayer(payload); if (payload.property === "pronouns") {
session.sendPlayerPronouns(payload);
} else {
session.sendPlayer(payload);
}
break; break;
} }
}); });

View File

@ -1,3 +1,5 @@
module.exports = { module.exports = {
// if the app is supposed to run on Github Pages in a subfolder, use the following config:
// publicPath: process.env.NODE_ENV === "production" ? "/townsquare/" : "/"
publicPath: process.env.NODE_ENV === "production" ? "/" : "/" publicPath: process.env.NODE_ENV === "production" ? "/" : "/"
}; };