Merge pull request #2 from bra1n/main

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

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

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

View File

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

View File

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

View File

@ -3,11 +3,11 @@
![social](https://user-images.githubusercontent.com/325521/102897760-d1147b00-4468-11eb-9d7b-63a204bc9fc1.png)
This is an unofficial online tool to run Blood on the Clocktower games through Discord or other digital means.
It is supposed to aid storytellers and allow them to quickly set up and capture game states for their players.
It is supposed to aid storytellers and players by allowing them to quickly set up games, run votes and much more.
[You can try it online!](https://clocktower.online)
If you want to learn more about how to use the website as a player, [JayBotC](https://www.youtube.com/channel/UCNZy-4Rp877XtTHaIZdWYFQ) kindly created two tutorial videos.
If you want to learn more about how to use the app as a player, [JayBotC](https://www.youtube.com/channel/UCNZy-4Rp877XtTHaIZdWYFQ) kindly created two tutorial videos.
### How to host a game
[![How to host a game](https://img.youtube.com/vi/lVRJPBXfqxg/0.jpg)](https://www.youtube.com/watch?v=lVRJPBXfqxg)
@ -20,8 +20,9 @@ If you want to learn more about how to use the website as a player, [JayBotC](ht
- Public Town Square and Storyteller Grimoire (toggle with **shortcut \[G\]**)
- 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)

15265
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "townsquare",
"version": "2.6.0",
"version": "2.10.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/mephit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 76 KiB

View File

@ -1,5 +1,18 @@
<template>
<div id="controls">
<span
class="nomlog-summary"
v-show="session.voteHistory.length && session.sessionId"
@click="toggleModal('voteHistory')"
:title="
`${session.voteHistory.length} recent ${
session.voteHistory.length == 1 ? 'nomination' : 'nominations'
}`
"
>
<font-awesome-icon icon="book-dead" />
{{ session.voteHistory.length }}
</span>
<span
class="session"
:class="{
@ -69,11 +82,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 +106,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 +227,7 @@ import { mapMutations, mapState } from "vuex";
export default {
computed: {
...mapState(["grimoire", "session"]),
...mapState(["grimoire", "session", "edition"]),
...mapState("players", ["players"])
},
data() {
@ -218,9 +242,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 +274,21 @@ export default {
);
}
},
imageOptIn() {
const popup =
"Are you sure you want to allow custom images? A malicious script file author might track your IP address this way.";
if (this.grimoire.isImageOptIn || confirm(popup)) {
this.toggleImageOptIn();
}
},
joinSession() {
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 +330,8 @@ export default {
...mapMutations([
"toggleGrimoire",
"toggleMenu",
"toggleImageOptIn",
"toggleMuted",
"toggleNight",
"toggleNightOrder",
"setZoom",
@ -346,6 +379,10 @@ export default {
margin-left: 10px;
}
span.nomlog-summary {
color: $townsfolk;
}
span.session {
color: $demon;
&.spectator {

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -25,7 +25,7 @@
>
<span class="name">
{{ role.name }}
<template v-if="role.players.length">
<span class="player" v-if="role.players.length">
<br />
<small
v-for="(player, index) in role.players"
@ -35,16 +35,24 @@
player.name + (role.players.length > index + 1 ? "," : "")
}}</small
>
</template>
</span>
</span>
<span
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,13 +66,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="name">
{{ role.name }}
<template v-if="role.players.length">
<span class="player" v-if="role.players.length">
<br />
<small
v-for="(player, index) in role.players"
@ -74,7 +87,10 @@
player.name + (role.players.length > index + 1 ? "," : "")
}}</small
>
</template>
</span>
</span>
<span class="reminder" v-if="role.otherNightReminder">
{{ role.otherNightReminder }}
</span>
</li>
</ul>
@ -99,16 +115,23 @@ export default {
{
id: "evil",
name: "Minion info",
firstNight: 2,
firstNight: 3,
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,
firstNight: 6,
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;
@ -319,4 +362,9 @@ ul {
}
}
}
/** hide players when town square is set to "public" **/
#townsquare.public ~ .night-reference .modal .player {
display: none;
}
</style>

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: {
@ -230,4 +235,9 @@ ul {
}
}
}
/** hide players when town square is set to "public" **/
#townsquare.public ~ .characters .modal .player {
display: none;
}
</style>

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

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

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])
@ -146,12 +168,15 @@ ul.tokens {
padding-left: 5%;
li {
border-radius: 50%;
width: 6vw;
margin: 1%;
width: 5vw;
margin: 5px;
opacity: 0.5;
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

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

View File

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

View File

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

View File

@ -10,12 +10,14 @@ import fabledJSON from "../fabled.json";
Vue.use(Vuex);
// 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,26 @@ 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;
}
})
// clean up role.id
.map(role => {
role.id = role.id.toLocaleLowerCase().replace(/[^a-z0-9]/g, "");
return role;
})
// map existing roles to base definition or pre-populate custom roles to ensure all properties
.map(
role =>
@ -175,16 +191,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

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

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);
@ -145,6 +164,10 @@ class LiveSession {
if (!this._isSpectator) return;
this._store.commit("players/move", params);
break;
case "remove":
if (!this._isSpectator) return;
this._store.commit("players/remove", params);
break;
case "isNight":
if (!this._isSpectator) return;
this._store.commit("toggleNight", params);
@ -170,6 +193,9 @@ class LiveSession {
case "bye":
this._handleBye(params);
break;
case "pronouns":
this._updatePlayerPronouns(params);
break;
}
}
@ -204,7 +230,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;
}
@ -213,26 +241,31 @@ 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,
id: player.id,
isDead: player.isDead,
isVoteless: player.isVoteless,
pronouns: player.pronouns,
...(player.role && player.role.team === "traveler"
? { roleId: player.role.id }
: {})
}));
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,
@ -279,7 +312,7 @@ class LiveSession {
const player = players[x];
const { roleId } = state;
// update relevant properties
["name", "id", "isDead", "isVoteless"].forEach(property => {
["name", "id", "isDead", "isVoteless", "pronouns"].forEach(property => {
const value = state[property];
if (player[property] !== value) {
this._store.commit("players/update", { player, property, value });
@ -322,18 +355,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 } : {})
});
}
@ -348,13 +380,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);
}
@ -461,42 +490,73 @@ class LiveSession {
}
/**
* Handle a ping message by another player / storyteller
* @param isSpectator
* @param playerId
* @param timestamp
* Publish a player pronouns update
* @param player
* @param value
* @param isFromSockets
*/
sendPlayerPronouns({ player, value, isFromSockets }) {
//send pronoun only for the seated player or storyteller
//Do not re-send pronoun data for an update that was recieved from the sockets layer
if (
isFromSockets ||
(this._isSpectator && this._store.state.session.playerId !== player.id)
)
return;
const index = this._store.state.players.players.indexOf(player);
this._send("pronouns", [index, value]);
}
/**
* Update a pronouns based on incoming data.
* @param index
* @param value
* @private
*/
_handlePing([isSpectator, playerId, 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];
}
}
// 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: ""
});
}
_updatePlayerPronouns([index, value]) {
const player = this._store.state.players.players[index];
this._store.commit("players/update", {
player,
property: "pronouns",
value,
isFromSockets: true
});
// 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) {
}
/**
* Handle a ping message by another player / storyteller
* @param playerIdOrCount
* @param latency
* @private
*/
_handlePing([playerIdOrCount = 0, latency] = []) {
const now = new Date().getTime();
if (!this._isSpectator) {
// remove players that haven't sent a ping in twice the timespan
for (let player in this._players) {
if (now - this._players[player] > this._pingInterval * 2) {
delete this._players[player];
delete this._pings[player];
}
}
// remove claimed seats from players that are no longer connected
this._store.state.players.players.forEach(player => {
if (player.id && !this._players[player.id]) {
this._store.commit("players/update", {
player,
property: "id",
value: ""
});
}
});
// store new player data
if (playerIdOrCount) {
this._players[playerIdOrCount] = now;
const ping = parseInt(latency, 10);
if (ping && ping > 0 && ping < 30 * 1000) {
// ping to Players
this._pings[playerId] = ping;
this._pings[playerIdOrCount] = ping;
const pings = Object.values(this._pings);
this._store.commit(
"session/setPing",
@ -504,19 +564,26 @@ class LiveSession {
);
}
}
} else if (latency) {
// ping to ST
this._store.commit("session/setPing", parseInt(latency, 10));
}
// update player count
if (!this._isSpectator || playerIdOrCount) {
this._store.commit(
"session/setPlayerCount",
this._isSpectator ? playerIdOrCount : Object.keys(this._players).length
);
}
this._store.commit(
"session/setPlayerCount",
Object.keys(this._players).length
);
}
/**
* Handle a player leaving the sessions
* Handle a player leaving the sessions. ST only
* @param playerId
* @private
*/
_handleBye(playerId) {
if (this._isSpectator) return;
delete this._players[playerId];
this._store.commit(
"session/setPlayerCount",
@ -721,6 +788,15 @@ class LiveSession {
if (this._isSpectator) return;
this._send("move", payload);
}
/**
* Remove a player. ST only
* @param payload
*/
removePlayer(payload) {
if (this._isSpectator) return;
this._send("remove", payload);
}
}
export default store => {
@ -728,11 +804,11 @@ export default store => {
const session = new LiveSession(store);
// listen to mutations
store.subscribe(({ type, payload }) => {
store.subscribe(({ type, payload }, state) => {
switch (type) {
case "session/setSessionId":
if (payload) {
session.connect(payload);
if (state.session.sessionId) {
session.connect(state.session.sessionId);
} else {
window.location.hash = "";
session.disconnect();
@ -779,14 +855,20 @@ export default store => {
case "players/move":
session.movePlayer(payload);
break;
case "players/remove":
session.removePlayer(payload);
break;
case "players/set":
case "players/clear":
case "players/remove":
case "players/add":
session.sendGamestate(true);
session.sendGamestate("", true);
break;
case "players/update":
session.sendPlayer(payload);
if (payload.property === "pronouns") {
session.sendPlayerPronouns(payload);
} else {
session.sendPlayer(payload);
}
break;
}
});

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