Merge branch 'main' of https://github.com/davotronic5000/townsquare into #43_gender_pronouns

This commit is contained in:
Dave 2021-02-28 10:30:44 +00:00
commit 2e930e6606
25 changed files with 635 additions and 347 deletions

View File

@ -1,5 +1,22 @@
# Release Notes
### 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
- night mode can be toggeled with [S] now (thanks @davotronic5000)
- night order shows which players are dead

View File

@ -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\]**)
- Supports custom script JSON generated by the [Script Tool](https://bloodontheclocktower.com/script)
- 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
- Full homebrew support for hosting and playing games with your own sets of characters
- Many other customization options!
### 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
your custom script - instead of "Custom Script" it would show "Deadly Penance Day" on the character reference sheet,
for example. The logo is shown only locally, if you want your players to see it as well, they will have to upload the
same JSON file that you used.
for example. The logo will be shown to your players after they have enabled custom images in the Grimoire menu.
### 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`
- **id**: the internal ID for this character, without spaces or special characters
- **image**: a URL to a PNG of the character token icon (should have a transparent background!)
- **id**: the internal ID for this character, without spaces or special characters<br>
_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"
- **firstNight** / **otherNight**: the position that this character acts on the first / other nights, compared to all
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`
- **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)
## [Contributing](CONTRIBUTING.md)

2
package-lock.json generated
View File

@ -1,6 +1,6 @@
{
"name": "townsquare",
"version": "2.6.0",
"version": "2.8.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

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

View File

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

View File

@ -69,11 +69,21 @@
/>
</em>
</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">
Background image
<em><font-awesome-icon icon="image"/></em>
</li>
<li @click="toggleMute">
<li @click="toggleMuted">
Mute Sounds
<em
><font-awesome-icon
@ -83,40 +93,41 @@
</template>
<template v-if="tab === 'session'">
<!-- Session -->
<li class="headline" v-if="session.sessionId">
{{ session.isSpectator ? "Playing" : "Hosting" }}
</li>
<li class="headline" v-else>
Live Session
</li>
<li @click="hostSession" v-if="!session.sessionId">
Host (Storyteller)<em>[H]</em>
</li>
<li @click="joinSession" v-if="!session.sessionId">
Join (Player)<em>[J]</em>
</li>
<li v-if="session.sessionId && session.ping">
Delay to {{ session.isSpectator ? "host" : "players" }}
<em>{{ session.ping }}ms</em>
</li>
<li v-if="session.sessionId" @click="copySessionUrl">
Copy player link
<em><font-awesome-icon icon="copy"/></em>
</li>
<li v-if="!session.isSpectator" @click="distributeRoles">
Send Characters
<em><font-awesome-icon icon="theater-masks"/></em>
</li>
<li
v-if="session.voteHistory.length"
@click="toggleModal('voteHistory')"
>
Nomination history<em>[V]</em>
</li>
<li @click="leaveSession" v-if="session.sessionId">
Leave Session
<em>{{ session.sessionId }}</em>
</li>
<template v-if="!session.sessionId">
<li @click="hostSession">Host (Storyteller)<em>[H]</em></li>
<li @click="joinSession">Join (Player)<em>[J]</em></li>
</template>
<template v-else>
<li v-if="session.ping">
Delay to {{ session.isSpectator ? "host" : "players" }}
<em>{{ session.ping }}ms</em>
</li>
<li @click="copySessionUrl">
Copy player link
<em><font-awesome-icon icon="copy"/></em>
</li>
<li v-if="!session.isSpectator" @click="distributeRoles">
Send Characters
<em><font-awesome-icon icon="theater-masks"/></em>
</li>
<li
v-if="session.voteHistory.length"
@click="toggleModal('voteHistory')"
>
Nomination history<em>[V]</em>
</li>
<li @click="leaveSession">
Leave Session
<em>{{ session.sessionId }}</em>
</li>
</template>
</template>
<template v-if="tab === 'players' && !session.isSpectator">
@ -203,7 +214,7 @@ import { mapMutations, mapState } from "vuex";
export default {
computed: {
...mapState(["grimoire", "session"]),
...mapState(["grimoire", "session", "edition"]),
...mapState("players", ["players"])
},
data() {
@ -218,9 +229,6 @@ export default {
this.$store.commit("setBackground", background);
}
},
toggleMute() {
this.$store.commit("setIsMuted", !this.grimoire.isMuted);
},
hostSession() {
if (this.session.sessionId) return;
const sessionId = prompt(
@ -253,11 +261,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() {
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"
);
if (sessionId.match(/^https?:\/\//i)) {
sessionId = sessionId.split("#").pop();
}
if (sessionId) {
this.$store.commit("session/clearVoteHistory");
this.$store.commit("session/setSpectator", true);
@ -299,6 +317,8 @@ export default {
...mapMutations([
"toggleGrimoire",
"toggleMenu",
"toggleImageOptIn",
"toggleMuted",
"toggleNight",
"toggleNightOrder",
"setZoom",

View File

@ -23,7 +23,7 @@
>
<em>{{ nightOrder.get(player).first }}.</em>
<span v-if="player.role.firstNightReminder">{{
player.role.firstNightReminder | handleEmojis
player.role.firstNightReminder
}}</span>
</div>
<div
@ -32,7 +32,7 @@
>
<em>{{ nightOrder.get(player).other }}.</em>
<span v-if="player.role.otherNightReminder">{{
player.role.otherNightReminder | handleEmojis
player.role.otherNightReminder
}}</span>
</div>
@ -174,8 +174,13 @@
<span
class="icon"
:style="{
backgroundImage: `url(${reminder.image ||
require('../assets/icons/' + reminder.role + '.png')})`
backgroundImage: `url(${
reminder.image && grimoire.isImageOptIn
? reminder.image
: require('../assets/icons/' +
(reminder.imageAlt || reminder.role) +
'.png')
})`
}"
></span>
<span class="text">{{ reminder.name }}</span>
@ -235,9 +240,6 @@ export default {
isSwap: false
};
},
filters: {
handleEmojis: text => text.replace(/:([^: ]+?):/g, "").replace(/ •/g, "\n•")
},
methods: {
changePronoun() {
if (!this.session.isSpectator || this.player.id !== this.session.playerId)

View File

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

View File

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

View File

@ -41,10 +41,18 @@
class="icon"
v-if="role.id"
:style="{
backgroundImage: `url(${role.image ||
require('../../assets/icons/' + role.id + '.png')})`
backgroundImage: `url(${
role.image && grimoire.isImageOptIn
? role.image
: require('../../assets/icons/' +
(role.imageAlt || role.id) +
'.png')
})`
}"
></span>
<span class="reminder" v-if="role.firstNightReminder">
{{ role.firstNightReminder }}
</span>
</li>
</ul>
<ul class="other">
@ -58,8 +66,13 @@
class="icon"
v-if="role.id"
:style="{
backgroundImage: `url(${role.image ||
require('../../assets/icons/' + role.id + '.png')})`
backgroundImage: `url(${
role.image && grimoire.isImageOptIn
? role.image
: require('../../assets/icons/' +
(role.imageAlt || role.id) +
'.png')
})`
}"
></span>
<span class="name">
@ -76,6 +89,9 @@
>
</template>
</span>
<span class="reminder" v-if="role.otherNightReminder">
{{ role.otherNightReminder }}
</span>
</li>
</ul>
</div>
@ -101,14 +117,21 @@ export default {
name: "Minion info",
firstNight: 2,
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",
name: "Demon info & bluffs",
firstNight: 4,
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
.filter(({ firstNight }) => firstNight)
.forEach(fabled => {
rolesFirstNight.push(fabled);
rolesFirstNight.push(Object.assign({ players: [] }, fabled));
});
rolesFirstNight.sort((a, b) => a.firstNight - b.firstNight);
return rolesFirstNight;
@ -137,7 +160,7 @@ export default {
this.fabled
.filter(({ otherNight }) => otherNight)
.forEach(fabled => {
rolesOtherNight.push(fabled);
rolesOtherNight.push(Object.assign({ players: [] }, fabled));
});
rolesOtherNight.sort((a, b) => a.otherNight - b.otherNight);
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 {
font-weight: bold;

View File

@ -34,8 +34,13 @@
class="icon"
v-if="role.id"
:style="{
backgroundImage: `url(${role.image ||
require('../../assets/icons/' + role.id + '.png')})`
backgroundImage: `url(${
role.image && grimoire.isImageOptIn
? role.image
: require('../../assets/icons/' +
(role.imageAlt || role.id) +
'.png')
})`
}"
></span>
<span class="ability">{{ role.ability }}</span>
@ -80,7 +85,7 @@ export default {
});
return players;
},
...mapState(["roles", "modals", "edition"]),
...mapState(["roles", "modals", "edition", "grimoire"]),
...mapState("players", ["players"])
},
methods: {

View File

@ -15,8 +15,13 @@
<span
class="icon"
:style="{
backgroundImage: `url(${reminder.image ||
require('../../assets/icons/' + reminder.role + '.png')})`
backgroundImage: `url(${
reminder.image && grimoire.isImageOptIn
? reminder.image
: require('../../assets/icons/' +
(reminder.imageAlt || reminder.role) +
'.png')
})`
}"
></span>
<span class="text">{{ reminder.name }}</span>
@ -29,6 +34,18 @@
import Modal from "./Modal";
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 {
components: { Modal },
props: ["playerIndex"],
@ -39,61 +56,29 @@ export default {
this.$store.state.roles.forEach(role => {
// add reminders from player roles
if (players.some(p => p.role.id === role.id)) {
reminders = [
...reminders,
...role.reminders.map(name => ({
role: role.id,
image: role.image,
name
}))
];
reminders = [...reminders, ...role.reminders.map(mapReminder(role))];
}
// add reminders from bluff/other roles
else if (bluffs.some(bluff => bluff.id === role.id)) {
reminders = [
...reminders,
...role.reminders.map(name => ({
role: role.id,
image: role.image,
name
}))
];
reminders = [...reminders, ...role.reminders.map(mapReminder(role))];
}
// add global reminders
if (role.remindersGlobal && role.remindersGlobal.length) {
reminders = [
...reminders,
...role.remindersGlobal.map(name => ({
role: role.id,
image: role.image,
name
}))
...role.remindersGlobal.map(mapReminder(role))
];
}
});
// add fabled reminders
this.$store.state.players.fabled.forEach(role => {
reminders = [
...reminders,
...role.reminders.map(name => ({
role: role.id,
image: role.image,
name
}))
];
reminders = [...reminders, ...role.reminders.map(mapReminder(role))];
});
// add out of script traveler reminders
this.$store.state.otherTravelers.forEach(role => {
if (players.some(p => p.role.id === role.id)) {
reminders = [
...reminders,
...role.reminders.map(name => ({
role: role.id,
image: role.image,
name
}))
];
reminders = [...reminders, ...role.reminders.map(mapReminder(role))];
}
});
@ -102,7 +87,7 @@ export default {
reminders.push({ role: "custom", name: "Custom note" });
return reminders;
},
...mapState(["modals"]),
...mapState(["modals", "grimoire"]),
...mapState("players", ["players"])
},
methods: {

View File

@ -7,22 +7,38 @@
<h3>Select the characters for {{ nonTravelers }} players:</h3>
<ul class="tokens" v-for="(teamRoles, team) in roleSelection" :key="team">
<li class="count" :class="[team]">
{{ teamRoles.filter(role => role.selected).length }} /
{{ teamRoles.reduce((a, { selected }) => a + selected, 0) }} /
{{ game[nonTravelers - 5][team] }}
</li>
<li
v-for="role in teamRoles"
:class="[role.team, role.selected ? 'selected' : '']"
:key="role.id"
@click="role.selected = !role.selected"
@click="role.selected = role.selected ? 0 : 1"
>
<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>
</ul>
<div class="warning" v-if="hasSelectedSetupRoles">
Warning: there are characters selected that modify the game setup! The
randomizer does not account for these characters.
<font-awesome-icon icon="exclamation-triangle" />
<span>
Warning: there are characters selected that modify the game setup! The
randomizer does not account for these characters.
</span>
</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"
@ -58,13 +74,14 @@ export default {
data: function() {
return {
roleSelection: {},
game: gameJSON
game: gameJSON,
allowMultiple: false
};
},
computed: {
selectedRoles: function() {
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);
},
hasSelectedSetupRoles: function() {
@ -84,7 +101,7 @@ export default {
this.$set(this.roleSelection, role.team, []);
}
this.roleSelection[role.team].push(role);
this.$set(role, "selected", false);
this.$set(role, "selected", 0);
});
delete this.roleSelection["traveler"];
const playerCount = Math.max(5, this.nonTravelers);
@ -93,10 +110,10 @@ export default {
for (let x = 0; x < composition[team]; x++) {
if (this.roleSelection[team]) {
const available = this.roleSelection[team].filter(
role => role.selected !== true
role => !role.selected
);
if (available.length) {
randomElement(available).selected = true;
randomElement(available).selected = 1;
}
}
}
@ -106,7 +123,12 @@ export default {
if (this.selectedRoles <= this.nonTravelers && this.selectedRoles) {
// generate list of selected roles and randomize it
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], [])
.map(a => [Math.random(), a])
.sort((a, b) => a[0] - b[0])
@ -152,6 +174,9 @@ ul.tokens {
transition: all 250ms;
&.selected {
opacity: 1;
.buttons {
display: flex;
}
}
&.townsfolk {
box-shadow: 0 0 10px $townsfolk, 0 0 10px #004cff;
@ -172,6 +197,27 @@ ul.tokens {
transform: scale(1.2);
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 {
opacity: 1;
@ -203,9 +249,51 @@ ul.tokens {
}
}
.roles .modal .warning {
color: red;
text-align: center;
margin: auto;
.roles .modal {
.multiple {
display: block;
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>

View File

@ -18,6 +18,7 @@ const faIcons = [
"Dice",
"Dragon",
"ExchangeAlt",
"ExclamationTriangle",
"FileCode",
"FileUpload",
"HandPaper",

View File

@ -4,7 +4,7 @@
"name": "Washerwoman",
"edition": "tb",
"team": "townsfolk",
"firstNight": 20,
"firstNight": 21,
"firstNightReminder": "Show the character token of a Townsfolk in play. Point to two players, one of which is that character.",
"otherNight": 0,
"otherNightReminder": "",
@ -18,7 +18,7 @@
"name": "Librarian",
"edition": "tb",
"team": "townsfolk",
"firstNight": 21,
"firstNight": 22,
"firstNightReminder": "Show the character token of an Outsider in play. Point to two players, one of which is that character.",
"otherNight": 0,
"otherNightReminder": "",
@ -32,7 +32,7 @@
"name": "Investigator",
"edition": "tb",
"team": "townsfolk",
"firstNight": 22,
"firstNight": 23,
"firstNightReminder": "Show the character token of a Minion in play. Point to two players, one of which is that character.",
"otherNight": 0,
"otherNightReminder": "",
@ -46,7 +46,7 @@
"name": "Chef",
"edition": "tb",
"team": "townsfolk",
"firstNight": 23,
"firstNight": 24,
"firstNightReminder": "Show the finger signal (0, 1, 2, \u2026) for the number of pairs of neighbouring evil players.",
"otherNight": 0,
"otherNightReminder": "",
@ -59,9 +59,9 @@
"name": "Empath",
"edition": "tb",
"team": "townsfolk",
"firstNight": 24,
"firstNight": 25,
"firstNightReminder": "Show the finger signal (0, 1, 2) for the number of evil alive neighbours of the Empath.",
"otherNight": 43,
"otherNight": 44,
"otherNightReminder": "Show the finger signal (0, 1, 2) for the number of evil neighbours.",
"reminders": [],
"setup": false,
@ -72,9 +72,9 @@
"name": "Fortune Teller",
"edition": "tb",
"team": "townsfolk",
"firstNight": 25,
"firstNight": 26,
"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": 45,
"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"],
"setup": false,
@ -87,7 +87,7 @@
"team": "townsfolk",
"firstNight": 0,
"firstNightReminder": "",
"otherNight": 46,
"otherNight": 47,
"otherNightReminder": "If a player was executed today: Show that player\u2019s character token.",
"reminders": ["Executed"],
"setup": false,
@ -113,7 +113,7 @@
"team": "townsfolk",
"firstNight": 0,
"firstNightReminder": "",
"otherNight": 41,
"otherNight": 42,
"otherNightReminder": "If the Ravenkeeper died tonight: The Ravenkeeper points to a player. Show that player\u2019s character token.",
"reminders": [],
"setup": false,
@ -176,9 +176,9 @@
"name": "Butler",
"edition": "tb",
"team": "outsider",
"firstNight": 26,
"firstNight": 27,
"firstNightReminder": "The Butler points to a player. Mark that player as 'Master'.",
"otherNight": 45,
"otherNight": 46,
"otherNightReminder": "The Butler points to a player. Mark that player as 'Master'.",
"reminders": ["Master"],
"setup": false,
@ -229,7 +229,7 @@
"name": "Poisoner",
"edition": "tb",
"team": "minion",
"firstNight": 9,
"firstNight": 10,
"firstNightReminder": "The Poisoner points to a player. That player is poisoned.",
"otherNight": 5,
"otherNightReminder": "The previously poisoned player is no longer poisoned. The Poisoner points to a player. That player is poisoned.",
@ -242,9 +242,9 @@
"name": "Spy",
"edition": "tb",
"team": "minion",
"firstNight": 34,
"firstNight": 35,
"firstNightReminder": "Show the Grimoire to the Spy for as long as they need.",
"otherNight": 56,
"otherNight": 57,
"otherNightReminder": "Show the Grimoire to the Spy for as long as they need.",
"reminders": [],
"setup": false,
@ -359,9 +359,9 @@
"name": "Grandmother",
"edition": "bmr",
"team": "townsfolk",
"firstNight": 27,
"firstNight": 28,
"firstNightReminder": "Show the marked character token. Point to the marked player.",
"otherNight": 39,
"otherNight": 40,
"otherNightReminder": "If the Grandmother\u2019s grandchild was killed by the Demon tonight: The Grandmother dies.",
"reminders": ["Grandchild"],
"setup": false,
@ -372,7 +372,7 @@
"name": "Sailor",
"edition": "bmr",
"team": "townsfolk",
"firstNight": 5,
"firstNight": 6,
"firstNightReminder": "The Sailor points to a living player. Either the Sailor, or the chosen player, is drunk.",
"otherNight": 2,
"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.",
@ -385,9 +385,9 @@
"name": "Chambermaid",
"edition": "bmr",
"team": "townsfolk",
"firstNight": 37,
"firstNight": 38,
"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": 60,
"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": [],
"setup": false,
@ -440,7 +440,7 @@
"team": "townsfolk",
"firstNight": 0,
"firstNightReminder": "",
"otherNight": 36,
"otherNight": 37,
"otherNightReminder": "If the Gossip\u2019s public statement was true: Choose a player not protected from dying tonight. That player dies.",
"reminders": ["Dead"],
"setup": false,
@ -451,8 +451,8 @@
"name": "Courtier",
"edition": "bmr",
"team": "townsfolk",
"firstNight": 11,
"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.",
"firstNight": 12,
"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,
"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",
@ -469,7 +469,7 @@
"team": "townsfolk",
"firstNight": 0,
"firstNightReminder": "",
"otherNight": 35,
"otherNight": 36,
"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",
"No ability"],
@ -535,7 +535,7 @@
"team": "outsider",
"firstNight": 0,
"firstNightReminder": "The Tinker might die.",
"otherNight": 37,
"otherNight": 38,
"otherNightReminder": "The Tinker might die.",
"reminders": ["Dead"],
"setup": false,
@ -548,7 +548,7 @@
"team": "outsider",
"firstNight": 0,
"firstNightReminder": "",
"otherNight": 38,
"otherNight": 39,
"otherNightReminder": "If the Moonchild used their ability to target a player today: If that player is good, they die.",
"reminders": ["Dead"],
"setup": false,
@ -572,7 +572,7 @@
"name": "Lunatic",
"edition": "bmr",
"team": "outsider",
"firstNight": 3,
"firstNight": 4,
"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,
"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.",
@ -587,7 +587,7 @@
"name": "Godfather",
"edition": "bmr",
"team": "minion",
"firstNight": 13,
"firstNight": 14,
"firstNightReminder": "Show each of the Outsider tokens in play.",
"otherNight": 31,
"otherNightReminder": "If an Outsider died today: The Godfather points to a player. That player dies.",
@ -601,7 +601,7 @@
"name": "Devil's Advocate",
"edition": "bmr",
"team": "minion",
"firstNight": 14,
"firstNight": 15,
"firstNightReminder": "The Devil\u2019s Advocate points to a living player. That player survives execution tomorrow.",
"otherNight": 11,
"otherNightReminder": "The Devil\u2019s Advocate points to a living player, different from the previous night. That player survives execution tomorrow.",
@ -655,7 +655,7 @@
"name": "Pukka",
"edition": "bmr",
"team": "demon",
"firstNight": 18,
"firstNight": 19,
"firstNightReminder": "The Pukka points to a player. That player is poisoned.",
"otherNight": 21,
"otherNightReminder": "The poisoned player dies. The Pukka points to a player. That player is poisoned.",
@ -671,7 +671,7 @@
"team": "demon",
"firstNight": 0,
"firstNightReminder": "",
"otherNight": 23,
"otherNight": 22,
"otherNightReminder": "One player that the Shabaloth chose the previous night might be resurrected. The Shabaloth points to two players. Those players die.",
"reminders": ["Dead",
"Alive"],
@ -685,7 +685,7 @@
"team": "demon",
"firstNight": 0,
"firstNightReminder": "",
"otherNight": 24,
"otherNight": 23,
"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",
"3 attacks"],
@ -763,7 +763,7 @@
"name": "Clockmaker",
"edition": "snv",
"team": "townsfolk",
"firstNight": 28,
"firstNight": 29,
"firstNightReminder": "Show the hand signal for the number (1, 2, 3, etc.) of places from Demon to closest Minion.",
"otherNight": 0,
"otherNightReminder": "",
@ -776,9 +776,9 @@
"name": "Dreamer",
"edition": "snv",
"team": "townsfolk",
"firstNight": 29,
"firstNight": 30,
"firstNightReminder": "The Dreamer points to a player. Show 1 good and 1 evil character token; one of these is correct.",
"otherNight": 47,
"otherNight": 48,
"otherNightReminder": "The Dreamer points to a player. Show 1 good and 1 evil character token; one of these is correct.",
"reminders": [],
"setup": false,
@ -789,7 +789,7 @@
"name": "Snake Charmer",
"edition": "snv",
"team": "townsfolk",
"firstNight": 12,
"firstNight": 13,
"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,
"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.",
@ -802,9 +802,9 @@
"name": "Mathematician",
"edition": "snv",
"team": "townsfolk",
"firstNight": 36,
"firstNight": 37,
"firstNightReminder": "Show the hand signal for the number (0, 1, 2, etc.) of players whose ability malfunctioned due to other abilities.",
"otherNight": 58,
"otherNight": 59,
"otherNightReminder": "Show the hand signal for the number (0, 1, 2, etc.) of players whose ability malfunctioned due to other abilities.",
"reminders": ["Abnormal"],
"setup": false,
@ -817,7 +817,7 @@
"team": "townsfolk",
"firstNight": 0,
"firstNightReminder": "Place the 'Demon not voted' marker.",
"otherNight": 48,
"otherNight": 49,
"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",
"Demon not voted"],
@ -831,7 +831,7 @@
"team": "townsfolk",
"firstNight": 0,
"firstNightReminder": "Place the 'Minions not nominated' marker.",
"otherNight": 49,
"otherNight": 50,
"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",
"Minion nominated"],
@ -845,7 +845,7 @@
"team": "townsfolk",
"firstNight": 0,
"firstNightReminder": "",
"otherNight": 50,
"otherNight": 51,
"otherNightReminder": "Show the hand signal for the number (0, 1, 2, etc.) of dead evil players.",
"reminders": [],
"setup": false,
@ -869,9 +869,9 @@
"name": "Seamstress",
"edition": "snv",
"team": "townsfolk",
"firstNight": 30,
"firstNight": 31,
"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": 52,
"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"],
"setup": false,
@ -887,7 +887,7 @@
"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.",
"reminders": ["Drunk",
"Is not the Philosopher"],
"Is the Philosopher"],
"setup": false,
"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",
"firstNight": 0,
"firstNightReminder": "",
"otherNight": 52,
"otherNight": 53,
"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"],
"setup": false,
@ -924,7 +924,7 @@
"team": "townsfolk",
"firstNight": 0,
"firstNightReminder": "",
"otherNight": 34,
"otherNight": 35,
"otherNightReminder": "If the Sage was killed by a Demon: Point to two players, one of which is that Demon.",
"reminders": [],
"setup": false,
@ -950,7 +950,7 @@
"team": "outsider",
"firstNight": 0,
"firstNightReminder": "",
"otherNight": 33,
"otherNight": 34,
"otherNightReminder": "Choose a player that is drunk.",
"reminders": ["Drunk"],
"setup": false,
@ -963,7 +963,7 @@
"team": "outsider",
"firstNight": 0,
"firstNightReminder": "",
"otherNight": 32,
"otherNight": 33,
"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"],
"setup": false,
@ -987,7 +987,7 @@
"name": "Evil Twin",
"edition": "snv",
"team": "minion",
"firstNight": 15,
"firstNight": 16,
"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,
"otherNightReminder": "",
@ -1000,7 +1000,7 @@
"name": "Witch",
"edition": "snv",
"team": "minion",
"firstNight": 16,
"firstNight": 17,
"firstNightReminder": "The Witch points to a player. If that player nominates tomorrow they die immediately.",
"otherNight": 12,
"otherNightReminder": "If there are 4 or more players alive: The Witch points to a player. If that player nominates tomorrow they die immediately.",
@ -1013,7 +1013,7 @@
"name": "Cerenovus",
"edition": "snv",
"team": "minion",
"firstNight": 17,
"firstNight": 18,
"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,
"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.",
@ -1041,7 +1041,7 @@
"team": "demon",
"firstNight": 0,
"firstNightReminder": "",
"otherNight": 25,
"otherNight": 24,
"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"],
"setup": true,
@ -1054,7 +1054,7 @@
"team": "demon",
"firstNight": 0,
"firstNightReminder": "",
"otherNight": 28,
"otherNight": 27,
"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",
"Has ability",
@ -1069,7 +1069,7 @@
"team": "demon",
"firstNight": 0,
"firstNightReminder": "",
"otherNight": 26,
"otherNight": 25,
"otherNightReminder": "The No Dashii points to a player. That player dies.",
"reminders": ["Dead",
"Poisoned"],
@ -1083,7 +1083,7 @@
"team": "demon",
"firstNight": 0,
"firstNightReminder": "",
"otherNight": 27,
"otherNight": 26,
"otherNightReminder": "The Vortox points to a player. That player dies.",
"reminders": ["Dead"],
"setup": false,
@ -1161,9 +1161,9 @@
"name": "Bounty Hunter",
"edition": "",
"team": "townsfolk",
"firstNight": 32,
"firstNight": 33,
"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": 55,
"otherNightReminder": "If the known evil player has died, point to another evil player. ",
"reminders": ["Known"],
"setup": true,
@ -1174,8 +1174,8 @@
"name": "Pixie",
"edition": "",
"team": "townsfolk",
"firstNight": 19,
"firstNightReminder": "Show the Pixie 1 in-play Townsfolk role.",
"firstNight": 20,
"firstNightReminder": "Show the Pixie 1 in-play Townsfolk character token.",
"otherNight": 0,
"otherNightReminder": "",
"reminders": ["Mad",
@ -1188,7 +1188,7 @@
"name": "Preacher",
"edition": "",
"team": "townsfolk",
"firstNight": 7,
"firstNight": 8,
"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.",
@ -1201,9 +1201,9 @@
"name": "General",
"edition": "",
"team": "townsfolk",
"firstNight": 35,
"firstNight": 36,
"firstNightReminder": "Show the General thumbs up for good winning, thumbs down for evil winning or thumb to the side for neither.",
"otherNight": 57,
"otherNight": 58,
"otherNightReminder": "Show the General thumbs up for good winning, thumbs down for evil winning or thumb to the side for neither.",
"reminders": [],
"setup": false,
@ -1214,9 +1214,9 @@
"name": "Balloonist",
"edition": "",
"team": "townsfolk",
"firstNight": 31,
"firstNight": 32,
"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": 54,
"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",
"Seen Outsider",
@ -1231,9 +1231,9 @@
"name": "Cult Leader",
"edition": "",
"team": "townsfolk",
"firstNight": 33,
"firstNight": 34,
"firstNightReminder": "If the cult leader changed alignment, show them the thumbs up good signal of the thumbs down evil signal accordingly.",
"otherNight": 55,
"otherNight": 56,
"otherNightReminder": "If the cult leader changed alignment, show them the thumbs up good signal of the thumbs down evil signal accordingly.",
"reminders": [],
"setup": false,
@ -1257,7 +1257,7 @@
"name": "Amnesiac",
"edition": "",
"team": "townsfolk",
"firstNight": 6,
"firstNight": 7,
"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,
"otherNightReminder": "If the Amnesiac's ability causes them to wake tonight: Wake the Amnesiac and run their ability.",
@ -1299,12 +1299,25 @@
"team": "outsider",
"firstNight": 0,
"firstNightReminder": "If a good living neighbour is drunk or poisoned, the Acrobat player dies.",
"otherNight": 22,
"otherNight": 32,
"otherNightReminder": "If a good living neighbour is drunk or poisoned, the Acrobat player dies.",
"reminders": ["Dead"],
"setup": false,
"ability": "Each night*, if either good living neighbour is drunk or poisoned, you die."
},
{
"id": "snitch",
"name": "Snitch",
"edition": "",
"team": "outsider",
"firstNight": 3,
"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": "politician",
"name": "Politician",
@ -1323,12 +1336,12 @@
"name": "Widow",
"edition": "",
"team": "minion",
"firstNight": 10,
"firstNight": 11,
"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,
"otherNightReminder": "",
"reminders": ["Poisoned",
"Knows"],
"reminders": ["Poisoned"],
"remindersGlobal": ["Knows"],
"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."
},
@ -1350,7 +1363,7 @@
"name": "Lil Monsta",
"edition": "",
"team": "demon",
"firstNight": 8,
"firstNight": 9,
"firstNightReminder": "",
"otherNight": 29,
"otherNightReminder": "Choose a player, that player dies.",
@ -1360,12 +1373,26 @@
"setup": true,
"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": 28,
"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",
"name": "Leviathan",
"edition": "",
"team": "demon",
"firstNight": 39,
"firstNight": 40,
"firstNightReminder": "Place the Leviathan 'Day 1' marker. Announce 'The Leviathan is in play; this is Day 1.'",
"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.'.",

View File

@ -10,12 +10,14 @@ import fabledJSON from "../fabled.json";
Vue.use(Vuex);
// global data maps
const editionJSONbyId = new Map(
editionJSON.map(edition => [edition.id, edition])
);
const rolesJSONbyId = new Map(rolesJSON.map(role => [role.id, role]));
const fabled = new Map(fabledJSON.map(role => [role.id, role]));
// helper functions
const getRolesByEdition = (edition = editionJSON[0]) => {
return new Map(
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
const imageBase =
"https://raw.githubusercontent.com/bra1n/townsquare/main/src/assets/icons/";
const customRole = {
id: "",
name: "",
image: "",
ability: "",
edition: "custom",
firstNight: 0,
firstNightReminder: "",
@ -67,6 +82,7 @@ export default new Vuex.Store({
isPublic: true,
isMenuOpen: false,
isMuted: false,
isImageOptIn: false,
zoom: 0,
background: ""
},
@ -88,27 +104,31 @@ export default new Vuex.Store({
},
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
* @returns {[]}
*/
customRoles: ({ roles }) => {
customRolesStripped: ({ roles }) => {
const customRoles = [];
const customKeys = Object.keys(customRole);
const strippedProps = [
"firstNightReminder",
"otherNightReminder",
"isCustom"
];
roles.forEach(role => {
if (!role.isCustom) {
customRoles.push({ id: role.id });
} else {
const strippedRole = {};
for (let prop in role) {
const value = role[prop];
if (
prop === "image" &&
value.toLocaleLowerCase().includes(imageBase)
) {
if (strippedProps.includes(prop)) {
continue;
}
if (prop !== "isCustom" && value !== customRole[prop]) {
strippedRole[prop] = value;
const value = role[prop];
if (customKeys.includes(prop) && value !== customRole[prop]) {
strippedRole[customKeys.indexOf(prop)] = value;
}
}
customRoles.push(strippedRole);
@ -119,38 +139,14 @@ export default new Vuex.Store({
rolesJSONbyId: () => rolesJSONbyId
},
mutations: {
toggleMenu({ grimoire }) {
grimoire.isMenuOpen = !grimoire.isMenuOpen;
},
toggleGrimoire({ grimoire }, isPublic) {
if (isPublic === true || isPublic === false) {
grimoire.isPublic = isPublic;
} else {
grimoire.isPublic = !grimoire.isPublic;
}
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;
},
setZoom: set("zoom"),
setBackground: set("background"),
toggleMuted: toggle("isMuted"),
toggleMenu: toggle("isMenuOpen"),
toggleNightOrder: toggle("isNightOrder"),
toggleNight: toggle("isNight"),
toggleGrimoire: toggle("isPublic"),
toggleImageOptIn: toggle("isImageOptIn"),
toggleModal({ modals }, name) {
if (name) {
modals[name] = !modals[name];
@ -168,6 +164,21 @@ export default new Vuex.Store({
setCustomRoles(state, roles) {
state.roles = new Map(
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;
}
})
// map existing roles to base definition or pre-populate custom roles to ensure all properties
.map(
role =>
@ -175,16 +186,16 @@ export default new Vuex.Store({
state.roles.get(role.id) ||
Object.assign({}, customRole, role)
)
// default empty icons to good / evil / traveler
// default empty icons and placeholders
.map(role => {
if (rolesJSONbyId.get(role.id)) return role;
if (role.team === "townsfolk" || role.team === "outsider") {
role.image = role.image || imageBase + "good.png";
} else if (role.team === "demon" || role.team === "minion") {
role.image = role.image || imageBase + "evil.png";
} else {
role.image = role.image || imageBase + "custom.png";
}
role.imageAlt = // map team to generic icon
{
townsfolk: "good",
outsider: "outsider",
minion: "minion",
demon: "evil"
}[role.team] || "custom";
return role;
})
// filter out roles that don't match an existing role and also don't have name/ability/team

View File

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

View File

@ -1,16 +1,25 @@
module.exports = store => {
const updatePagetitle = isPublic =>
(document.title = `Blood on the Clocktower ${
isPublic ? "Town Square" : "Grimoire"
}`);
// initialize data
if (localStorage.getItem("background")) {
store.commit("setBackground", localStorage.background);
}
if (localStorage.getItem("muted")) {
store.commit("setIsMuted", true);
store.commit("toggleMuted", true);
}
if (localStorage.getItem("imageOptIn")) {
store.commit("toggleImageOptIn", true);
}
if (localStorage.getItem("zoom")) {
store.commit("setZoom", parseFloat(localStorage.getItem("zoom")));
}
if (localStorage.isPublic !== undefined) {
store.commit("toggleGrimoire", JSON.parse(localStorage.isPublic));
if (localStorage.getItem("isGrimoire")) {
store.commit("toggleGrimoire", false);
updatePagetitle(false);
}
if (localStorage.roles !== undefined) {
store.commit("setCustomRoles", JSON.parse(localStorage.roles));
@ -61,10 +70,12 @@ module.exports = store => {
store.subscribe(({ type, payload }, state) => {
switch (type) {
case "toggleGrimoire":
localStorage.setItem(
"isPublic",
JSON.stringify(state.grimoire.isPublic)
);
if (!state.grimoire.isPublic) {
localStorage.setItem("isGrimoire", 1);
} else {
localStorage.removeItem("isGrimoire");
}
updatePagetitle(state.grimoire.isPublic);
break;
case "setBackground":
if (payload) {
@ -73,13 +84,20 @@ module.exports = store => {
localStorage.removeItem("background");
}
break;
case "setIsMuted":
if (payload) {
case "toggleMuted":
if (state.grimoire.isMuted) {
localStorage.setItem("muted", 1);
} else {
localStorage.removeItem("muted");
}
break;
case "toggleImageOptIn":
if (state.grimoire.isImageOptIn) {
localStorage.setItem("imageOptIn", 1);
} else {
localStorage.removeItem("imageOptIn");
}
break;
case "setZoom":
if (payload !== 0) {
localStorage.setItem("zoom", payload);
@ -97,10 +115,7 @@ module.exports = store => {
if (!payload.length) {
localStorage.removeItem("roles");
} else {
localStorage.setItem(
"roles",
JSON.stringify(store.getters.customRoles)
);
localStorage.setItem("roles", JSON.stringify(payload));
}
break;
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.
* @private
*/
_onOpen() {
if (this._isSpectator) {
this._send("req", "gs");
this._sendDirect(
"host",
"getGamestate",
this._store.state.session.playerId
);
} else {
this.sendGamestate();
}
@ -80,12 +100,13 @@ class LiveSession {
* @private
*/
_ping() {
this._handlePing();
this._send("ping", [
this._isSpectator,
this._store.state.session.playerId,
this._isSpectator
? this._store.state.session.playerId
: Object.keys(this._players).length,
"latency"
]);
this._handlePing();
clearTimeout(this._pingTimer);
this._pingTimer = setTimeout(this._ping.bind(this), this._pingInterval);
}
@ -103,10 +124,8 @@ class LiveSession {
console.log("unsupported socket message", data);
}
switch (command) {
case "req":
if (params === "gs") {
this.sendGamestate();
}
case "getGamestate":
this.sendGamestate(params);
break;
case "edition":
this._updateEdition(params);
@ -207,7 +226,9 @@ class LiveSession {
this._store.commit("session/setReconnecting", false);
clearTimeout(this._reconnectTimer);
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 = null;
}
@ -216,9 +237,10 @@ class LiveSession {
/**
* Publish the current gamestate.
* Optional param to reduce traffic. (send only player data)
* @param playerId
* @param isLightweight
*/
sendGamestate(isLightweight = false) {
sendGamestate(playerId = "", isLightweight = false) {
if (this._isSpectator) return;
this._gamestate = this._store.state.players.players.map(player => ({
name: player.name,
@ -231,12 +253,15 @@ class LiveSession {
: {})
}));
if (isLightweight) {
this._send("gs", { gamestate: this._gamestate, isLightweight });
this._sendDirect(playerId, "gs", {
gamestate: this._gamestate,
isLightweight
});
} else {
const { session, grimoire } = this._store.state;
const { fabled } = this._store.state.players;
this.sendEdition();
this._send("gs", {
this.sendEdition(playerId);
this._sendDirect(playerId, "gs", {
gamestate: this._gamestate,
isNight: grimoire.isNight,
nomination: session.nomination,
@ -326,18 +351,17 @@ class LiveSession {
/**
* Publish an edition update. ST only
* @param playerId
*/
sendEdition() {
sendEdition(playerId = "") {
if (this._isSpectator) return;
const { edition } = this._store.state;
let roles;
if (!edition.isOfficial) {
roles = Array.from(this._store.state.roles.keys());
roles = this._store.getters.customRolesStripped;
}
this._send("edition", {
edition: edition.isOfficial
? { id: edition.id }
: Object.assign({}, edition, { logo: "" }),
this._sendDirect(playerId, "edition", {
edition: edition.isOfficial ? { id: edition.id } : edition,
...(roles ? { roles } : {})
});
}
@ -352,13 +376,10 @@ class LiveSession {
if (!this._isSpectator) return;
this._store.commit("setEdition", edition);
if (roles) {
this._store.commit(
"setCustomRoles",
roles.map(id => ({ id }))
);
this._store.commit("setCustomRoles", roles);
if (this._store.state.roles.size !== roles.length) {
const missing = [];
roles.forEach(id => {
roles.forEach(({ id }) => {
if (!this._store.state.roles.get(id)) {
missing.push(id);
}
@ -484,41 +505,37 @@ class LiveSession {
/**
* Handle a ping message by another player / storyteller
* @param isSpectator
* @param playerId
* @param timestamp
* @param playerIdOrCount
* @param latency
* @private
*/
_handlePing([isSpectator, playerId, latency] = []) {
_handlePing([playerIdOrCount = 0, latency] = []) {
const now = new Date().getTime();
// 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];
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 (!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);
if (ping && ping > 0 && ping < 30 * 1000) {
if (this._isSpectator && !isSpectator) {
// ping to ST
this._store.commit("session/setPing", ping);
} else if (!this._isSpectator) {
// 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
this._pings[playerId] = ping;
this._pings[playerIdOrCount] = ping;
const pings = Object.values(this._pings);
this._store.commit(
"session/setPing",
@ -526,19 +543,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
* @private
*/
_handleBye(playerId) {
if (this._isSpectator) return;
delete this._players[playerId];
this._store.commit(
"session/setPlayerCount",
@ -805,7 +829,7 @@ export default store => {
case "players/clear":
case "players/remove":
case "players/add":
session.sendGamestate(true);
session.sendGamestate("", true);
break;
case "players/update":
session.sendPlayer(payload);

View File

@ -1,3 +1,5 @@
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" ? "/" : "/"
};