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 # 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 ## 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

@ -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)

2
package-lock.json generated
View File

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

View File

@ -1,6 +1,6 @@
{ {
"name": "townsquare", "name": "townsquare",
"version": "2.6.0", "version": "2.8.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,12 +150,31 @@ 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
channels[ws.channel].forEach(function each(client) {
if (
client !== ws &&
client.readyState === WebSocket.OPEN &&
(ws.playerId === "host" || client.playerId === "host")
) {
client.send(
data.replace(/latency/, (client.latency || 0) + (ws.latency || 0))
);
metrics.messages_outgoing.inc();
} }
});
break;
case '"direct"':
// handle "direct" messages differently // handle "direct" messages differently
if (messageType === '"direct"') { console.log(
new Date(),
wss.clients.size,
ws.channel,
ws.playerId,
data
);
try { try {
const dataToPlayer = JSON.parse(data)[1]; const dataToPlayer = JSON.parse(data)[1];
channels[ws.channel].forEach(function each(client) { channels[ws.channel].forEach(function each(client) {
@ -171,19 +190,23 @@ wss.on("connection", function connection(ws, req) {
} catch (e) { } catch (e) {
console.log("error parsing direct message JSON", e); console.log("error parsing direct message JSON", e);
} }
} else { break;
default:
// all other messages // all other messages
console.log(
new Date(),
wss.clients.size,
ws.channel,
ws.playerId,
data
);
channels[ws.channel].forEach(function each(client) { channels[ws.channel].forEach(function each(client) {
if (client !== ws && client.readyState === WebSocket.OPEN) { 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); client.send(data);
}
metrics.messages_outgoing.inc(); 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/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> </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,23 +93,23 @@
</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 v-if="session.sessionId" @click="copySessionUrl"> <li @click="copySessionUrl">
Copy player link Copy player link
<em><font-awesome-icon icon="copy"/></em> <em><font-awesome-icon icon="copy"/></em>
</li> </li>
@ -113,11 +123,12 @@
> >
Nomination history<em>[V]</em> Nomination history<em>[V]</em>
</li> </li>
<li @click="leaveSession" v-if="session.sessionId"> <li @click="leaveSession">
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">
<!-- Users --> <!-- Users -->
@ -203,7 +214,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 +229,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 +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() { 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 +317,8 @@ export default {
...mapMutations([ ...mapMutations([
"toggleGrimoire", "toggleGrimoire",
"toggleMenu", "toggleMenu",
"toggleImageOptIn",
"toggleMuted",
"toggleNight", "toggleNight",
"toggleNightOrder", "toggleNightOrder",
"setZoom", "setZoom",

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>
@ -174,8 +174,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>
@ -235,9 +240,6 @@ export default {
isSwap: false isSwap: false
}; };
}, },
filters: {
handleEmojis: text => text.replace(/:([^: ]+?):/g, "").replace(/ •/g, "\n•")
},
methods: { methods: {
changePronoun() { changePronoun() {
if (!this.session.isSpectator || this.player.id !== this.session.playerId) if (!this.session.isSpectator || this.player.id !== this.session.playerId)

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

@ -41,10 +41,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="reminder" v-if="role.firstNightReminder">
{{ role.firstNightReminder }}
</span>
</li> </li>
</ul> </ul>
<ul class="other"> <ul class="other">
@ -58,8 +66,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="name"> <span class="name">
@ -76,6 +89,9 @@
> >
</template> </template>
</span> </span>
<span class="reminder" v-if="role.otherNightReminder">
{{ role.otherNightReminder }}
</span>
</li> </li>
</ul> </ul>
</div> </div>
@ -101,14 +117,21 @@ export default {
name: "Minion info", name: "Minion info",
firstNight: 2, firstNight: 2,
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: 4,
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;

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: {

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

@ -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">
<font-awesome-icon icon="exclamation-triangle" />
<span>
Warning: there are characters selected that modify the game setup! The Warning: there are characters selected that modify the game setup! The
randomizer does not account for these characters. 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])
@ -152,6 +174,9 @@ ul.tokens {
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 {
display: block;
text-align: center; text-align: center;
margin: auto; 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

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

View File

@ -4,7 +4,7 @@
"name": "Washerwoman", "name": "Washerwoman",
"edition": "tb", "edition": "tb",
"team": "townsfolk", "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.", "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": 22,
"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": 23,
"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": 24,
"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": 25,
"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": 44,
"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": 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. ", "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.", "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": 47,
"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,
@ -113,7 +113,7 @@
"team": "townsfolk", "team": "townsfolk",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "firstNightReminder": "",
"otherNight": 41, "otherNight": 42,
"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": 27,
"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": 46,
"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,7 +229,7 @@
"name": "Poisoner", "name": "Poisoner",
"edition": "tb", "edition": "tb",
"team": "minion", "team": "minion",
"firstNight": 9, "firstNight": 10,
"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": 5,
"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.",
@ -242,9 +242,9 @@
"name": "Spy", "name": "Spy",
"edition": "tb", "edition": "tb",
"team": "minion", "team": "minion",
"firstNight": 34, "firstNight": 35,
"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": 57,
"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,
@ -359,9 +359,9 @@
"name": "Grandmother", "name": "Grandmother",
"edition": "bmr", "edition": "bmr",
"team": "townsfolk", "team": "townsfolk",
"firstNight": 27, "firstNight": 28,
"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": 40,
"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,7 +372,7 @@
"name": "Sailor", "name": "Sailor",
"edition": "bmr", "edition": "bmr",
"team": "townsfolk", "team": "townsfolk",
"firstNight": 5, "firstNight": 6,
"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": 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.", "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", "name": "Chambermaid",
"edition": "bmr", "edition": "bmr",
"team": "townsfolk", "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.", "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.", "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,
@ -440,7 +440,7 @@
"team": "townsfolk", "team": "townsfolk",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "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.", "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,8 +451,8 @@
"name": "Courtier", "name": "Courtier",
"edition": "bmr", "edition": "bmr",
"team": "townsfolk", "team": "townsfolk",
"firstNight": 11, "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.", "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": 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.", "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",
@ -469,7 +469,7 @@
"team": "townsfolk", "team": "townsfolk",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "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.", "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": 38,
"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": 39,
"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,7 +572,7 @@
"name": "Lunatic", "name": "Lunatic",
"edition": "bmr", "edition": "bmr",
"team": "outsider", "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.", "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": 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.", "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", "name": "Godfather",
"edition": "bmr", "edition": "bmr",
"team": "minion", "team": "minion",
"firstNight": 13, "firstNight": 14,
"firstNightReminder": "Show each of the Outsider tokens in play.", "firstNightReminder": "Show each of the Outsider tokens in play.",
"otherNight": 31, "otherNight": 31,
"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.",
@ -601,7 +601,7 @@
"name": "Devil's Advocate", "name": "Devil's Advocate",
"edition": "bmr", "edition": "bmr",
"team": "minion", "team": "minion",
"firstNight": 14, "firstNight": 15,
"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": 11,
"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.",
@ -655,7 +655,7 @@
"name": "Pukka", "name": "Pukka",
"edition": "bmr", "edition": "bmr",
"team": "demon", "team": "demon",
"firstNight": 18, "firstNight": 19,
"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": 21,
"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.",
@ -671,7 +671,7 @@
"team": "demon", "team": "demon",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "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.", "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": 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", "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"],
@ -763,7 +763,7 @@
"name": "Clockmaker", "name": "Clockmaker",
"edition": "snv", "edition": "snv",
"team": "townsfolk", "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.", "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": 30,
"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": 48,
"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,7 +789,7 @@
"name": "Snake Charmer", "name": "Snake Charmer",
"edition": "snv", "edition": "snv",
"team": "townsfolk", "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.", "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": 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.", "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", "name": "Mathematician",
"edition": "snv", "edition": "snv",
"team": "townsfolk", "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.", "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.", "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": 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).", "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": 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).", "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": 51,
"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": 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.", "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.", "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": 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.", "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": 35,
"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": 34,
"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": 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.", "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": 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.", "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,7 +1000,7 @@
"name": "Witch", "name": "Witch",
"edition": "snv", "edition": "snv",
"team": "minion", "team": "minion",
"firstNight": 16, "firstNight": 17,
"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": 12,
"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.",
@ -1013,7 +1013,7 @@
"name": "Cerenovus", "name": "Cerenovus",
"edition": "snv", "edition": "snv",
"team": "minion", "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.", "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": 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.", "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", "team": "demon",
"firstNight": 0, "firstNight": 0,
"firstNightReminder": "", "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.", "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": 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.", "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": 25,
"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": 26,
"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,
@ -1161,9 +1161,9 @@
"name": "Bounty Hunter", "name": "Bounty Hunter",
"edition": "", "edition": "",
"team": "townsfolk", "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.", "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. ", "otherNightReminder": "If the known evil player has died, point to another evil player. ",
"reminders": ["Known"], "reminders": ["Known"],
"setup": true, "setup": true,
@ -1174,8 +1174,8 @@
"name": "Pixie", "name": "Pixie",
"edition": "", "edition": "",
"team": "townsfolk", "team": "townsfolk",
"firstNight": 19, "firstNight": 20,
"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": "",
"reminders": ["Mad", "reminders": ["Mad",
@ -1188,7 +1188,7 @@
"name": "Preacher", "name": "Preacher",
"edition": "", "edition": "",
"team": "townsfolk", "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.", "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, "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.", "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", "name": "General",
"edition": "", "edition": "",
"team": "townsfolk", "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.", "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.", "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,
@ -1214,9 +1214,9 @@
"name": "Balloonist", "name": "Balloonist",
"edition": "", "edition": "",
"team": "townsfolk", "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.", "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.", "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": 34,
"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": 56,
"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,
@ -1257,7 +1257,7 @@
"name": "Amnesiac", "name": "Amnesiac",
"edition": "", "edition": "",
"team": "townsfolk", "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.", "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": 3,
"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.",
@ -1299,12 +1299,25 @@
"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": 32,
"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,
"ability": "Each night*, if either good living neighbour is drunk or poisoned, you die." "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", "id": "politician",
"name": "Politician", "name": "Politician",
@ -1323,12 +1336,12 @@
"name": "Widow", "name": "Widow",
"edition": "", "edition": "",
"team": "minion", "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.", "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."
}, },
@ -1350,7 +1363,7 @@
"name": "Lil Monsta", "name": "Lil Monsta",
"edition": "", "edition": "",
"team": "demon", "team": "demon",
"firstNight": 8, "firstNight": 9,
"firstNightReminder": "", "firstNightReminder": "",
"otherNight": 29, "otherNight": 29,
"otherNightReminder": "Choose a player, that player dies.", "otherNightReminder": "Choose a player, that player dies.",
@ -1360,12 +1373,26 @@
"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": 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", "id": "leviathan",
"name": "Leviathan", "name": "Leviathan",
"edition": "", "edition": "",
"team": "demon", "team": "demon",
"firstNight": 39, "firstNight": 40,
"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.'.",

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,21 @@ 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;
}
})
// 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 +186,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

@ -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);
@ -207,7 +226,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;
} }
@ -216,9 +237,10 @@ 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,
@ -231,12 +253,15 @@ class LiveSession {
: {}) : {})
})); }));
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,
@ -326,18 +351,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 } : {})
}); });
} }
@ -352,13 +376,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);
} }
@ -484,13 +505,13 @@ class LiveSession {
/** /**
* Handle a ping message by another player / storyteller * Handle a ping message by another player / storyteller
* @param isSpectator * @param playerIdOrCount
* @param playerId * @param latency
* @param timestamp
* @private * @private
*/ */
_handlePing([isSpectator, playerId, latency] = []) { _handlePing([playerIdOrCount = 0, latency] = []) {
const now = new Date().getTime(); const now = new Date().getTime();
if (!this._isSpectator) {
// remove players that haven't sent a ping in twice the timespan // remove players that haven't sent a ping in twice the timespan
for (let player in this._players) { for (let player in this._players) {
if (now - this._players[player] > this._pingInterval * 2) { if (now - this._players[player] > this._pingInterval * 2) {
@ -500,7 +521,7 @@ class LiveSession {
} }
// remove claimed seats from players that are no longer connected // remove claimed seats from players that are no longer connected
this._store.state.players.players.forEach(player => { this._store.state.players.players.forEach(player => {
if (!this._isSpectator && player.id && !this._players[player.id]) { if (player.id && !this._players[player.id]) {
this._store.commit("players/update", { this._store.commit("players/update", {
player, player,
property: "id", property: "id",
@ -509,16 +530,12 @@ class LiveSession {
} }
}); });
// store new player data // store new player data
if (playerId) { if (playerIdOrCount) {
this._players[playerId] = now; this._players[playerIdOrCount] = now;
const ping = parseInt(latency, 10); const ping = parseInt(latency, 10);
if (ping && ping > 0 && ping < 30 * 1000) { if (ping && ping > 0 && ping < 30 * 1000) {
if (this._isSpectator && !isSpectator) {
// ping to ST
this._store.commit("session/setPing", ping);
} else if (!this._isSpectator) {
// 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",
@ -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( this._store.commit(
"session/setPlayerCount", "session/setPlayerCount",
Object.keys(this._players).length this._isSpectator ? playerIdOrCount : 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",
@ -805,7 +829,7 @@ export default store => {
case "players/clear": case "players/clear":
case "players/remove": 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); session.sendPlayer(payload);

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" ? "/" : "/"
}; };