Merge branch 'master' into voting

This commit is contained in:
Steffen 2020-05-27 21:48:33 +02:00
commit 2aeb9f66c6
No known key found for this signature in database
GPG Key ID: 764D74E98267DFC6
24 changed files with 906 additions and 173 deletions

32
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,32 @@
---
name: Bug report
about: Create a report to help us improve
title: "Bug: "
labels: bug
assignees: bra1n
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@ -0,0 +1,20 @@
---
name: Feature Request
about: Suggest an idea for this project
title: "Feature Request: "
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

76
CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,76 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, sex characteristics, gender identity and expression,
level of experience, education, socio-economic status, nationality, personal
appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at steffen@baumgart.biz. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq

81
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,81 @@
# Contributing Guide
Hi! I'm really excited that you are interested in contributing to this project.
Before submitting your contribution, please make sure to take a moment and read through the following guidelines:
- [Code of Conduct](CODE_OF_CONDUCT.md)
- [Issue Reporting Guidelines](#issue-reporting-guidelines)
- [Pull Request Guidelines](#pull-request-guidelines)
- [Development Setup](#development-setup)
- [Project Structure](#project-structure)
## Issue Reporting Guidelines
- Report a new issue either through [GitHub](https://github.com/bra1n/townsquare/issues/new/choose) or [Email](mailto:steffen@baumgart.biz)
- Please include with your issue the browser and operating system you're using as well as steps necessary to reproduce the bug, if any
- Be patient, I'm working on this project in my spare time :-)
## Pull Request Guidelines
- The `master` branch is what is currently deployed to the website. All development should be done in dedicated branches.
- Work in the `src` folder and **DO NOT** checkin `dist` in the commits.
- It's OK to have multiple small commits as you work on the PR - GitHub will automatically squash it before merging.
- If adding a new feature:
- Provide a convincing reason to add this feature. Ideally, you should open a suggestion issue first before working on it.
- Feel free to write a test for it, but so far I didn't have time for that.
- If fixing a bug:
- If you are resolving a special issue, add `(fix #xxxx[,#xxxx])` (#xxxx is the issue id) in your PR title for a better release log, e.g. `update entities encoding/decoding (fix #3899)`.
- Provide a detailed description of the bug in the PR. Live demo preferred.
## Development Setup
You will need [Node.js](http://nodejs.org) **version 8+** and a Chrome browser.
After cloning the repo, run:
``` bash
$ npm install
```
The development server can be started with `npm run serve`.
### Committing Changes
Commit messages should be verbose enough to allow someone else to follow your changes and should include references to issues that are being worked on.
### Commonly used NPM scripts
``` bash
# watch and auto re-build dist/
$ npm run serve
# build all dist files, including npm packages
$ npm run build
# run linting scripts
$ npm run link
```
## Project Structure
- **`dist`**: contains built files for distribution.
- **`src`**: contains the source code. The codebase is written in ES2015.
- **`assets`**: contains all graphical assets like images, fonts, icons, etc.
- **`editions`**: edition logos and icons
- **`fonts`**: webfonts used on the page
- **`icons`**: character token icons
- **`components`**: the internal components used in the project
- **`modals`**: the modals have a separate subfolder
- **`store`**: the VueX data store and modules

View File

@ -7,14 +7,26 @@ It is supposed to aid storytellers and allow them to quickly set up and capture
[You can try it online!](https://bra1n.github.io/townsquare) [You can try it online!](https://bra1n.github.io/townsquare)
WORK IN PROGRESS ### Features
- 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
- Includes all 3 base editions and travelers
- Night sheet and reminder text for each character ability to help storytellers
- Many other customization options!
## [Code of Conduct](CODE_OF_CONDUCT.md)
## [Contributing](CONTRIBUTING.md)
## Acknowledgements and Copyrights ## Acknowledgements and Copyrights
* [Blood on the Clocktower](https://bloodontheclocktower.com/) images, names and abilities by [The Pandemonium Institute](https://www.thepandemoniuminstitute.com/) * [Blood on the Clocktower](https://bloodontheclocktower.com/) names and abilities by [The Pandemonium Institute](https://www.thepandemoniuminstitute.com/)
* Night reminders and other auxiliary text written by [Ben Finney](http://bignose.whitetree.org/projects/botc/diy/) * Night reminders and other auxiliary text written by [Ben Finney](http://bignose.whitetree.org/projects/botc/diy/)
* Iconography by [Font Awesome](https://fontawesome.com/) * Iconography by [Font Awesome](https://fontawesome.com/)
* Background image by [Ryan Maloney](https://www.artstation.com/maloney94) * Background image by [Ryan Maloney](https://www.artstation.com/maloney94)
* Webfonts by [Google Fonts](https://fonts.google.com/) and [Online Web Fonts](https://www.onlinewebfonts.com/) * Webfonts by [Google Fonts](https://fonts.google.com/) and [Online Web Fonts](https://www.onlinewebfonts.com/)
* All other images and icons are copyright to their respective owners
This project and its website are not affiliated with The Pandemonium Institute in any way. This project and its website are provided free of charge and not affiliated with The Pandemonium Institute in any way.

View File

@ -14,8 +14,9 @@
<TownInfo v-if="players.length"></TownInfo> <TownInfo v-if="players.length"></TownInfo>
<TownSquare @screenshot="takeScreenshot"></TownSquare> <TownSquare @screenshot="takeScreenshot"></TownSquare>
<Menu ref="menu"></Menu> <Menu ref="menu"></Menu>
<EditionModal></EditionModal> <EditionModal />
<RolesModal></RolesModal> <RolesModal />
<ReferenceModal />
</div> </div>
</template> </template>
@ -27,9 +28,11 @@ import Menu from "./components/Menu";
import RolesModal from "./components/modals/RolesModal"; import RolesModal from "./components/modals/RolesModal";
import EditionModal from "./components/modals/EditionModal"; import EditionModal from "./components/modals/EditionModal";
import Intro from "./components/Intro"; import Intro from "./components/Intro";
import ReferenceModal from "./components/modals/ReferenceModal";
export default { export default {
components: { components: {
ReferenceModal,
Intro, Intro,
TownInfo, TownInfo,
TownSquare, TownSquare,
@ -46,7 +49,7 @@ export default {
this.$refs.menu.takeScreenshot(dimensions); this.$refs.menu.takeScreenshot(dimensions);
}, },
keyup({ key }) { keyup({ key }) {
switch (key) { switch (key.toLocaleLowerCase()) {
case "g": case "g":
this.$store.commit("toggleGrimoire"); this.$store.commit("toggleGrimoire");
break; break;
@ -54,7 +57,7 @@ export default {
this.$refs.menu.addPlayer(); this.$refs.menu.addPlayer();
break; break;
case "r": case "r":
this.$refs.menu.randomizeSeatings(); this.$store.commit("toggleModal", "reference");
break; break;
case "e": case "e":
if (this.session.isSpectator) return; if (this.session.isSpectator) return;
@ -86,6 +89,12 @@ export default {
url("assets/fonts/papyrus.svg#PapyrusW01") format("svg"); /* iOS 4.1- */ url("assets/fonts/papyrus.svg#PapyrusW01") format("svg"); /* iOS 4.1- */
} }
@font-face {
font-family: PiratesBay;
src: url("assets/fonts/piratesbay.ttf");
font-display: swap;
}
html, html,
body { body {
font-size: 1.2em; font-size: 1.2em;
@ -121,6 +130,9 @@ h4,
h5 { h5 {
margin: 0; margin: 0;
text-align: center; text-align: center;
font-family: PiratesBay, sans-serif;
letter-spacing: 1px;
font-weight: normal;
} }
ul { ul {

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

View File

@ -1,11 +1,11 @@
<template> <template>
<div class="intro" > <div class="intro">
<img src="static/apple-icon.png" alt="" /> <img src="static/apple-icon.png" alt="" />
Welcome to the (unofficial) Welcome to the (unofficial)
<b> Virtual Blood on the Clocktower Town Square</b>!<br /> <b> Virtual Blood on the Clocktower Town Square</b>!<br />
Please add more players through the Please add more players through the
<span class="button"> <span class="button" @click="toggleMenu">
<font-awesome-icon icon="cog" @click="toggleMenu" /> Menu <font-awesome-icon icon="cog" /> Menu
</span> </span>
on the top right or by pressing <b>[A]</b>.<br /> on the top right or by pressing <b>[A]</b>.<br />
This project is free and open source and can be found on This project is free and open source and can be found on

View File

@ -1,19 +1,26 @@
<template> <template>
<div id="controls"> <div id="controls">
<Screenshot ref="screenshot"></Screenshot> <Screenshot ref="screenshot"></Screenshot>
<font-awesome-icon <span
@click="leaveSession" class="session"
icon="broadcast-tower" :class="{ spectator: session.isSpectator }"
v-if="session.sessionId" v-if="session.sessionId"
v-bind:class="{ spectator: session.isSpectator }" @click="leaveSession"
title="You're currently in a live game!" :title="
/> `You're currently in a live game with ${session.playerCount} other players!`
"
>
<font-awesome-icon icon="broadcast-tower" />
{{ session.playerCount }}
</span>
<span class="camera">
<font-awesome-icon <font-awesome-icon
icon="camera" icon="camera"
@click="takeScreenshot()" @click="takeScreenshot()"
title="Take a screenshot" title="Take a screenshot"
v-bind:class="{ success: grimoire.isScreenshotSuccess }" :class="{ success: grimoire.isScreenshotSuccess }"
/> />
</span>
<div class="menu" v-bind:class="{ open: grimoire.isMenuOpen }"> <div class="menu" v-bind:class="{ open: grimoire.isMenuOpen }">
<font-awesome-icon icon="cog" @click="toggleMenu" /> <font-awesome-icon icon="cog" @click="toggleMenu" />
<ul> <ul>
@ -79,12 +86,18 @@
<li @click="clearPlayers" v-if="players.length"> <li @click="clearPlayers" v-if="players.length">
Remove all Remove all
</li> </li>
</template>
<!-- Characters --> <!-- Characters -->
<li class="headline"> <li class="headline">
<font-awesome-icon icon="theater-masks" /> <font-awesome-icon icon="theater-masks" />
Characters Characters
</li> </li>
<li @click="toggleModal('reference')">
<em>[R]</em>
Reference Sheet
</li>
<template v-if="!session.isSpectator">
<li @click="toggleModal('edition')"> <li @click="toggleModal('edition')">
<em>[E]</em> <em>[E]</em>
Select Edition Select Edition
@ -120,10 +133,10 @@ export default {
this.$refs.screenshot.capture(dimensions); this.$refs.screenshot.capture(dimensions);
}, },
setBackground() { setBackground() {
this.$store.commit( const background = prompt("Enter custom background URL");
"setBackground", if (background || background === "") {
prompt("Enter custom background URL") this.$store.commit("setBackground", background);
); }
}, },
hostSession() { hostSession() {
const sessionId = prompt( const sessionId = prompt(
@ -241,14 +254,15 @@ export default {
} }
} }
> svg { > span {
display: inline-block;
cursor: pointer; cursor: pointer;
z-index: 5; z-index: 5;
margin-top: 10px; margin-top: 7px;
margin-left: 10px; margin-left: 10px;
} }
> .fa-broadcast-tower { .session {
color: $demon; color: $demon;
&.spectator { &.spectator {
color: $townsfolk; color: $townsfolk;
@ -317,9 +331,10 @@ export default {
} }
.headline { .headline {
font-family: PiratesBay, sans-serif;
letter-spacing: 1px;
padding: 5px 10px; padding: 5px 10px;
text-align: center; text-align: center;
font-weight: bold;
background: linear-gradient( background: linear-gradient(
to right, to right,
$townsfolk 0%, $townsfolk 0%,

View File

@ -37,14 +37,20 @@
icon="times-circle" icon="times-circle"
class="cancel" class="cancel"
title="Cancel" title="Cancel"
@click="doSwap(true)" @click="cancel()"
/> />
<font-awesome-icon <font-awesome-icon
icon="exchange-alt" icon="exchange-alt"
class="swap" class="swap"
@click="doSwap()" @click="swapPlayer(player)"
title="Swap seats with this player" title="Swap seats with this player"
/> />
<font-awesome-icon
icon="redo-alt"
class="move"
@click="movePlayer(player)"
title="Move player to this seat"
/>
<font-awesome-icon <font-awesome-icon
icon="vote-yea" icon="vote-yea"
@ -65,14 +71,17 @@
<transition name="fold"> <transition name="fold">
<ul class="menu" v-if="isMenuOpen && !session.isSpectator"> <ul class="menu" v-if="isMenuOpen && !session.isSpectator">
<li @click="changeName"> <li @click="changeName">
<font-awesome-icon icon="user-edit" /> <font-awesome-icon icon="user-edit" />Rename
Rename
</li> </li>
<!--<li @click="nomination"> <!--<li @click="nomination">
<font-awesome-icon icon="hand-point-right" /> <font-awesome-icon icon="hand-point-right" />
Nomination Nomination
</li>--> </li>-->
<li @click="initSwap"> <li @click="movePlayer()">
<font-awesome-icon icon="redo-alt" />
Move player
</li>
<li @click="swapPlayer()">
<font-awesome-icon icon="exchange-alt" /> <font-awesome-icon icon="exchange-alt" />
Swap seats Swap seats
</li> </li>
@ -182,12 +191,16 @@ export default {
value value
}); });
}, },
initSwap() { swapPlayer(player) {
this.isMenuOpen = false; this.isMenuOpen = false;
this.$emit("swap-seats"); this.$emit("swap-player", player);
}, },
doSwap(cancel) { movePlayer(player) {
this.$emit("swap-seats", cancel ? false : this.player); this.isMenuOpen = false;
this.$emit("move-player", player);
},
cancel() {
this.$emit("cancel");
} }
} }
}; };
@ -243,6 +256,10 @@ export default {
pointer-events: none; pointer-events: none;
} }
#townsquare.spectator & {
pointer-events: none;
}
#townsquare:not(.spectator) &:hover:before { #townsquare:not(.spectator) &:hover:before {
opacity: 0.5; opacity: 0.5;
top: -10px; top: -10px;
@ -349,6 +366,7 @@ export default {
z-index: 2; z-index: 2;
cursor: pointer; cursor: pointer;
&.swap, &.swap,
&.move,
&.cancel { &.cancel {
top: 9%; top: 9%;
left: 20%; left: 20%;
@ -364,13 +382,14 @@ export default {
} }
} }
li.swap-from .player > svg.cancel { li.from .player > svg.cancel {
opacity: 1; opacity: 1;
transform: scale(1); transform: scale(1);
pointer-events: all; pointer-events: all;
} }
li.swap:not(.swap-from) .player > svg.swap { li.swap:not(.from) .player > svg.swap,
li.move:not(.from) .player > svg.move {
opacity: 1; opacity: 1;
transform: scale(1); transform: scale(1);
pointer-events: all; pointer-events: all;
@ -603,7 +622,7 @@ li.swap:not(.swap-from) .player > svg.swap {
display: block; display: block;
margin: 5px ($token / -4) 0; margin: 5px ($token / -4) 0;
text-align: center; text-align: center;
padding: ($token * 0.3 + 2px) 5px 0; padding: ($token * 0.3 + 5px) 5px 0;
border-radius: 50%; border-radius: 50%;
line-height: 90%; line-height: 90%;
border: 3px solid black; border: 3px solid black;
@ -639,6 +658,19 @@ li.swap:not(.swap-from) .player > svg.swap {
} }
} }
&.custom {
padding: 5px;
display: flex;
align-items: center;
align-content: center;
justify-content: center;
font-size: 70%;
word-break: break-word;
.icon {
display: none;
}
}
&:hover:before { &:hover:before {
opacity: 0; opacity: 0;
} }

View File

@ -16,11 +16,15 @@
@add-reminder="openReminderModal(index)" @add-reminder="openReminderModal(index)"
@set-role="openRoleModal(index)" @set-role="openRoleModal(index)"
@remove-player="removePlayer(index)" @remove-player="removePlayer(index)"
@swap-seats="swapSeats(index, $event)" @cancel="cancel(index)"
@swap-player="swapPlayer(index, $event)"
@move-player="movePlayer(index, $event)"
@screenshot="$emit('screenshot', $event)" @screenshot="$emit('screenshot', $event)"
v-bind:class="{ v-bind:class="{
'swap-from': swapFrom === index, from: Math.max(swap, move, nominate) === index,
swap: swapFrom > -1 swap: swap > -1,
move: move > -1,
nominate: nominate > -1
}" }"
></Player> ></Player>
</ul> </ul>
@ -66,7 +70,9 @@ export default {
return { return {
selectedPlayer: 0, selectedPlayer: 0,
bluffs: 3, bluffs: 3,
swapFrom: -1 swap: -1,
move: -1,
nominate: -1
}; };
}, },
methods: { methods: {
@ -80,7 +86,8 @@ export default {
}, },
openRoleModal(playerIndex) { openRoleModal(playerIndex) {
const player = this.players[playerIndex]; const player = this.players[playerIndex];
if (this.session.isSpectator && player.role.team === "traveler") return; if (this.session.isSpectator && player && player.role.team === "traveler")
return;
this.selectedPlayer = playerIndex; this.selectedPlayer = playerIndex;
this.$store.commit("toggleModal", "role"); this.$store.commit("toggleModal", "role");
}, },
@ -94,18 +101,34 @@ export default {
this.$store.commit("players/remove", playerIndex); this.$store.commit("players/remove", playerIndex);
} }
}, },
swapSeats(from, to) { swapPlayer(from, to) {
if (to === undefined) { if (to === undefined) {
this.swapFrom = from; this.cancel();
} else if (to === false) { this.swap = from;
this.swapFrom = -1;
} else { } else {
this.$store.commit("players/swap", [ this.$store.commit("players/swap", [
this.swapFrom, this.swap,
this.players.indexOf(to) this.players.indexOf(to)
]); ]);
this.swapFrom = -1; this.cancel();
} }
},
movePlayer(from, to) {
if (to === undefined) {
this.cancel();
this.move = from;
} else {
this.$store.commit("players/move", [
this.move,
this.players.indexOf(to)
]);
this.cancel();
}
},
cancel() {
this.move = -1;
this.swap = -1;
this.nominate = -1;
} }
} }
}; };

View File

@ -4,6 +4,7 @@
v-show="modals.edition" v-show="modals.edition"
@close="toggleModal('edition')" @close="toggleModal('edition')"
> >
<div v-if="!isCustom">
<h3>Select an edition:</h3> <h3>Select an edition:</h3>
<ul class="editions"> <ul class="editions">
<li <li
@ -15,7 +16,48 @@
> >
{{ edition.name }} {{ edition.name }}
</li> </li>
<li class="edition edition-custom" @click="isCustom = true">
Custom Script
</li>
</ul> </ul>
</div>
<div class="custom" v-else>
<h3>Upload a custom script</h3>
To play with a custom script, you need to select the characters you want
to play with in the official
<a href="https://bloodontheclocktower.com/script-tool/" target="_blank"
>Script Tool</a
>
and then upload the generated "custom-list.json" either directly here or
provide a URL to such a hosted JSON file.<br />
<h3>Some custom Teensyville scripts:</h3>
<ul class="scripts">
<li
v-for="(script, index) in scripts"
v-bind:key="index"
@click="handleURL(script[1])"
>
{{ script[0] }}
</li>
</ul>
<input
type="file"
ref="upload"
accept="application/json"
@change="handleUpload"
/>
<div class="button-group">
<div class="button" @click="openUpload">
<font-awesome-icon icon="file-upload" /> Upload JSON
</div>
<div class="button" @click="promptURL">
<font-awesome-icon icon="link" /> Enter URL
</div>
<div class="button" @click="isCustom = false">
<font-awesome-icon icon="undo" /> Back
</div>
</div>
</div>
</Modal> </Modal>
</template> </template>
@ -30,11 +72,61 @@ export default {
}, },
data: function() { data: function() {
return { return {
editions: editionJSON editions: editionJSON,
isCustom: false,
scripts: [
[
"On Thin Ice",
"https://gist.githubusercontent.com/bra1n/8dacd9f2abc6f428331ea1213ab153f5/raw/9758aff4b59965dc7a094db549d950be5a26b571/custom-script.json"
],
[
"Race To The Bottom",
"https://gist.githubusercontent.com/bra1n/63e1354cb3dc9d4032bcd0623dc48888/raw/5be4df8386ec61e3a98c32be77f8cac3f8414379/custom-script.json"
],
[
"Frankenstein's Mayor by Ted",
"https://gist.githubusercontent.com/bra1n/32c52b422cc01b934a4291eeb81dbcee/raw/3ca5a043c41141ac40667dc15097deb327263268/Frankensteins_Mayor_by_Ted.json"
]
]
}; };
}, },
computed: mapState(["modals"]), computed: mapState(["modals"]),
methods: mapMutations(["toggleModal", "setEdition"]) methods: {
openUpload() {
this.$refs.upload.click();
},
handleUpload() {
const file = this.$refs.upload.files[0];
if (file && file.size) {
const reader = new FileReader();
reader.addEventListener("load", () => {
this.parseScript(JSON.parse(reader.result));
});
reader.readAsText(file);
}
},
promptURL() {
const url = prompt("Enter URL to a custom-script.json file");
if (url) {
this.handleURL(url);
}
},
async handleURL(url) {
const res = await fetch(url);
if (res && res.json) {
const script = await res.json();
this.parseScript(script);
}
},
parseScript(script) {
if (!script || !script.length) return;
const roles = script.map(({ id }) => id.replace(/[^a-z]/g, ""));
this.$store.commit("setRoles", roles);
this.$store.commit("setEdition", "custom");
this.isCustom = false;
},
...mapMutations(["toggleModal", "setEdition"])
}
}; };
</script> </script>
@ -63,15 +155,16 @@ export default {
} }
ul.editions .edition { ul.editions .edition {
font-family: PiratesBay, sans-serif;
letter-spacing: 1px;
text-align: center; text-align: center;
padding-top: 100px; padding-top: 100px;
background-position: center center; background-position: center center;
background-size: 100% auto; background-size: 100% auto;
background-repeat: no-repeat; background-repeat: no-repeat;
width: 200px; width: 30%;
margin: 5px; margin: 5px;
font-size: 120%; font-size: 120%;
font-weight: bold;
text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000,
1px 1px 0 #000, 0 0 5px rgba(0, 0, 0, 0.75); 1px 1px 0 #000, 0 0 5px rgba(0, 0, 0, 0.75);
cursor: pointer; cursor: pointer;
@ -79,4 +172,23 @@ ul.editions .edition {
color: red; color: red;
} }
} }
.custom {
text-align: center;
input[type="file"] {
display: none;
}
.scripts {
list-style-type: disc;
font-size: 120%;
cursor: pointer;
display: block;
width: 50%;
text-align: left;
margin: 10px auto;
li:hover {
color: red;
}
}
}
</style> </style>

View File

@ -48,6 +48,11 @@ export default {
flex-direction: column; flex-direction: column;
max-width: 60%; max-width: 60%;
.characters & {
max-height: 80%;
overflow-y: auto;
}
ul { ul {
list-style-type: none; list-style-type: none;
margin: 0; margin: 0;

View File

@ -0,0 +1,212 @@
<template>
<Modal
class="characters"
v-show="modals.reference"
@close="toggleModal('reference')"
v-if="roles.size"
>
<h3>{{ editionName }} Character Reference</h3>
<ul class="legend">
<li>
<span class="name">Name</span>
<span class="icon">Icon</span>
<span class="ability">Ability</span>
<span class="player" v-if="Object.keys(playersByRole).length">
Player
</span>
</li>
</ul>
<div v-for="(teamRoles, team) in rolesGrouped" :key="team" :class="[team]">
<h4>{{ team }}</h4>
<ul>
<li v-for="role in teamRoles" :class="[team]" :key="role.id">
<span class="name">{{ role.name }}</span>
<span
class="icon"
v-if="role.id"
v-bind:style="{
backgroundImage: `url(${require('../../assets/icons/' +
role.id +
'.png')})`
}"
></span>
<span class="ability">{{ role.ability }}</span>
<span class="player" v-if="Object.keys(playersByRole).length">{{
playersByRole[role.id] ? playersByRole[role.id].join(", ") : ""
}}</span>
</li>
</ul>
</div>
</Modal>
</template>
<script>
import Modal from "./Modal";
import editionJSON from "./../../editions.json";
import { mapMutations, mapState } from "vuex";
export default {
components: {
Modal
},
data: function() {
return {
roleSelection: {}
};
},
computed: {
editionName: function() {
const edition = editionJSON.find(({ id }) => id === this.edition);
return edition ? edition.name : "Custom script";
},
rolesGrouped: function() {
const rolesGrouped = {};
this.roles.forEach(role => {
if (!rolesGrouped[role.team]) {
rolesGrouped[role.team] = [];
}
rolesGrouped[role.team].push(role);
});
delete rolesGrouped["traveler"];
return rolesGrouped;
},
playersByRole: function() {
const players = {};
this.players.forEach(({ name, role }) => {
if (role && role.id && role.team !== "traveler") {
if (!players[role.id]) {
players[role.id] = [];
}
players[role.id].push(name);
}
});
return players;
},
...mapState(["roles", "modals", "edition"]),
...mapState("players", ["players"])
},
methods: {
...mapMutations(["toggleModal"])
}
};
</script>
<style lang="scss" scoped>
@import "../../vars.scss";
h4 {
text-transform: capitalize;
display: flex;
align-items: center;
height: 20px;
&:before,
&:after {
content: " ";
width: 100%;
height: 1px;
border-radius: 2px;
}
&:before {
margin-right: 15px;
}
&:after {
margin-left: 15px;
}
}
.townsfolk {
.name,
.player,
h4 {
color: $townsfolk;
&:before,
&:after {
background-color: $townsfolk;
}
}
}
.outsider {
.name,
.player,
h4 {
color: $outsider;
&:before,
&:after {
background-color: $outsider;
}
}
}
.minion {
.name,
.player,
h4 {
color: $minion;
&:before,
&:after {
background-color: $minion;
}
}
}
.demon {
.name,
.player,
h4 {
color: $demon;
&:before,
&:after {
background-color: $demon;
}
}
}
ul {
li {
display: flex;
width: 100%;
align-items: center;
align-content: center;
/*background: linear-gradient(0deg, #ffffff0f, transparent);*/
border-radius: 10px;
.icon {
width: 60px;
height: 40px;
background-size: cover;
background-position: 0 -5px;
flex-grow: 0;
flex-shrink: 0;
margin: 0 10px;
text-align: center;
border-left: 1px solid #ffffff1f;
border-right: 1px solid #ffffff1f;
}
.name {
flex-grow: 0;
flex-shrink: 0;
width: 150px;
font-weight: bold;
text-align: right;
font-family: "Papyrus", sans-serif;
font-size: 110%;
}
.player {
flex-grow: 0;
flex-shrink: 1;
text-align: right;
margin: 0 10px;
}
.ability {
flex-grow: 1;
}
}
&.legend {
font-weight: bold;
height: 20px;
margin-top: 10px;
li span {
background: none;
height: auto;
font-family: inherit;
font-size: inherit;
}
}
}
</style>

View File

@ -9,13 +9,13 @@
<li <li
v-for="reminder in availableReminders" v-for="reminder in availableReminders"
class="reminder" class="reminder"
v-bind:class="[reminder.role]" :class="[reminder.role]"
v-bind:key="reminder.role + ' ' + reminder.name" :key="reminder.role + ' ' + reminder.name"
@click="addReminder(reminder)" @click="addReminder(reminder)"
> >
<span <span
class="icon" class="icon"
v-bind:style="{ :style="{
backgroundImage: `url(${require('../../assets/icons/' + backgroundImage: `url(${require('../../assets/icons/' +
reminder.role + reminder.role +
'.png')})` '.png')})`
@ -48,6 +48,7 @@ export default {
}); });
reminders.push({ role: "good", name: "Good" }); reminders.push({ role: "good", name: "Good" });
reminders.push({ role: "evil", name: "Evil" }); reminders.push({ role: "evil", name: "Evil" });
reminders.push({ role: "custom", name: "Custom note" });
return reminders; return reminders;
}, },
...mapState(["modals"]), ...mapState(["modals"]),
@ -56,7 +57,14 @@ export default {
methods: { methods: {
addReminder(reminder) { addReminder(reminder) {
const player = this.$store.state.players.players[this.playerIndex]; const player = this.$store.state.players.players[this.playerIndex];
const value = [...player.reminders, reminder]; let value;
if (reminder.role === "custom") {
const name = prompt("Add a custom reminder note");
if (!name) return;
value = [...player.reminders, { role: "custom", name }];
} else {
value = [...player.reminders, reminder];
}
this.$store.commit("players/update", { this.$store.commit("players/update", {
player, player,
property: "reminders", property: "reminders",
@ -85,7 +93,7 @@ ul.reminders .reminder {
border: 3px solid black; border: 3px solid black;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
cursor: pointer; cursor: pointer;
padding: 65px 9px 0; padding: 70px 9px 0;
line-height: 100%; line-height: 100%;
transition: transform 500ms ease; transition: transform 500ms ease;

View File

@ -49,7 +49,7 @@ export default {
availableRoles.push({}); availableRoles.push({});
return availableRoles; return availableRoles;
}, },
...mapState(["modals", "roles"]), ...mapState(["modals", "roles", "session"]),
...mapState("players", ["players"]) ...mapState("players", ["players"])
}, },
methods: { methods: {
@ -61,6 +61,7 @@ export default {
role role
}); });
} else { } else {
if (this.session.isSpectator && role.team === "traveler") return;
// assign to player // assign to player
const player = this.$store.state.players.players[this.playerIndex]; const player = this.$store.state.players.players[this.playerIndex];
this.$store.commit("players/update", { this.$store.commit("players/update", {
@ -105,4 +106,8 @@ ul.tokens li {
z-index: 10; z-index: 10;
} }
} }
#townsquare.spectator ul.tokens li.traveler {
display: none;
}
</style> </style>

View File

@ -2,57 +2,38 @@ import Vue from "vue";
import App from "./App"; import App from "./App";
import store from "./store"; import store from "./store";
import { library } from "@fortawesome/fontawesome-svg-core"; import { library } from "@fortawesome/fontawesome-svg-core";
import { import { fas } from "@fortawesome/free-solid-svg-icons";
faBookOpen,
faCamera,
faCog,
faHeartbeat,
faSearchMinus,
faSearchPlus,
faTheaterMasks,
faTimesCircle,
faUser,
faUserEdit,
faUserFriends,
faUsers,
faVoteYea,
faCheckSquare,
faSquare,
faRandom,
faPeopleArrows,
faBroadcastTower,
faCopy,
faExchangeAlt,
faHandPointRight
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
library.add( const faIcons = [
faBookOpen, "BookOpen",
faCamera, "BroadcastTower",
faCog, "Camera",
faHeartbeat, "CheckSquare",
faSearchMinus, "Cog",
faSearchPlus, "Copy",
faTheaterMasks, "ExchangeAlt",
faTimesCircle, "FileUpload",
faUser, "HandPointRight",
faUserEdit, "Heartbeat",
faUserFriends, "Link",
faUsers, "PeopleArrows",
faVoteYea, "Random",
faCheckSquare, "RedoAlt",
faSquare, "SearchMinus",
faRandom, "SearchPlus",
faPeopleArrows, "Square",
faBroadcastTower, "TheaterMasks",
faCopy, "TimesCircle",
faExchangeAlt, "Undo",
faHandPointRight "User",
); "UserEdit",
"UserFriends",
"Users",
"VoteYea"
];
library.add(...faIcons.map(i => fas["fa" + i]));
Vue.component("font-awesome-icon", FontAwesomeIcon); Vue.component("font-awesome-icon", FontAwesomeIcon);
Vue.config.productionTip = false; Vue.config.productionTip = false;
new Vue({ new Vue({

View File

@ -38,9 +38,11 @@ export default new Vuex.Store({
}, },
session: { session: {
sessionId: "", sessionId: "",
isSpectator: false isSpectator: false,
playerCount: 0
}, },
modals: { modals: {
reference: false,
edition: false, edition: false,
roles: false, roles: false,
role: false, role: false,
@ -78,6 +80,9 @@ export default new Vuex.Store({
setSpectator({ session }, spectator) { setSpectator({ session }, spectator) {
session.isSpectator = spectator; session.isSpectator = spectator;
}, },
setPlayerCount({ session }, playerCount) {
session.playerCount = playerCount;
},
setBluff({ grimoire }, { index, role } = {}) { setBluff({ grimoire }, { index, role } = {}) {
if (index !== undefined) { if (index !== undefined) {
grimoire.bluffs.splice(index, 1, role); grimoire.bluffs.splice(index, 1, role);
@ -87,6 +92,12 @@ export default new Vuex.Store({
}, },
toggleModal({ modals }, name) { toggleModal({ modals }, name) {
modals[name] = !modals[name]; modals[name] = !modals[name];
if (modals[name]) {
for (let modal in modals) {
if (modal === name) continue;
modals[modal] = false;
}
}
}, },
updateScreenshot({ grimoire }, status) { updateScreenshot({ grimoire }, status) {
if (status !== true && status !== false) { if (status !== true && status !== false) {
@ -97,11 +108,21 @@ export default new Vuex.Store({
grimoire.isScreenshot = false; grimoire.isScreenshot = false;
} }
}, },
setRoles(state, roles) {
state.roles = new Map(
rolesJSON
.filter(r => roles.includes(r.id))
.sort((a, b) => b.team.localeCompare(a.team))
.map(role => [role.id, role])
);
},
setEdition(state, edition) { setEdition(state, edition) {
state.edition = edition; state.edition = edition;
state.modals.edition = false; state.modals.edition = false;
if (edition !== "custom") {
state.roles = getRolesByEdition(edition); state.roles = getRolesByEdition(edition);
} }
}
}, },
plugins: [persistence, session] plugins: [persistence, session]
}); });

View File

@ -84,6 +84,9 @@ const mutations = {
state.players[to], state.players[to],
state.players[from] state.players[from]
]; ];
},
move(state, [from, to]) {
state.players.splice(to, 0, state.players.splice(from, 1)[0]);
} }
}; };

View File

@ -10,6 +10,9 @@ module.exports = store => {
// this will initialize state.roles! // this will initialize state.roles!
store.commit("setEdition", localStorage.edition); store.commit("setEdition", localStorage.edition);
} }
if (localStorage.roles !== undefined) {
store.commit("setRoles", JSON.parse(localStorage.roles));
}
if (localStorage.bluffs !== undefined) { if (localStorage.bluffs !== undefined) {
JSON.parse(localStorage.bluffs).forEach((role, index) => { JSON.parse(localStorage.bluffs).forEach((role, index) => {
store.commit("setBluff", { store.commit("setBluff", {
@ -50,7 +53,19 @@ module.exports = store => {
} }
break; break;
case "setEdition": case "setEdition":
if (payload === "custom") {
localStorage.removeItem("edition");
} else {
localStorage.setItem("edition", payload); localStorage.setItem("edition", payload);
localStorage.removeItem("roles");
}
break;
case "setRoles":
if (!payload.length) {
localStorage.removeItem("roles");
} else {
localStorage.setItem("roles", JSON.stringify(payload));
}
break; break;
case "setBluff": case "setBluff":
localStorage.setItem( localStorage.setItem(
@ -74,6 +89,7 @@ module.exports = store => {
case "players/clear": case "players/clear":
case "players/set": case "players/set":
case "players/swap": case "players/swap":
case "players/move":
if (state.players.players.length) { if (state.players.players.length) {
localStorage.setItem( localStorage.setItem(
"players", "players",

View File

@ -1,11 +1,22 @@
class LiveSession { class LiveSession {
constructor(store) { constructor(store) {
this.wss = "wss://connect.websocket.in/v3/"; this._wss = "wss://connect.websocket.in/v3/";
this.key = "zXzDomOphNQ94tWXrHfT8E8gkxjUMSXOQt0ypZetKoFsIUiEBegqWNAlExyd"; this._key = "zXzDomOphNQ94tWXrHfT8E8gkxjUMSXOQt0ypZetKoFsIUiEBegqWNAlExyd";
this.socket = null; this._socket = null;
this.isSpectator = true; this._isSpectator = true;
this.gamestate = []; this._gamestate = [];
this.store = store; this._store = store;
this._pingInterval = 30 * 1000; // 30 seconds between pings
this._pingTimer = null;
this._players = {}; // map of players connected to a session
this._playerId = Math.random()
.toString(36)
.substr(2);
// reconnect to previous session
if (this._store.state.session.sessionId) {
this.connect(this._store.state.session.sessionId);
}
} }
/** /**
@ -15,12 +26,14 @@ class LiveSession {
*/ */
_open(channel) { _open(channel) {
this.disconnect(); this.disconnect();
this.socket = new WebSocket(this.wss + channel + "?apiKey=" + this.key); this._socket = new WebSocket(this._wss + channel + "?apiKey=" + this._key);
this.socket.addEventListener("message", this._handleMessage.bind(this)); this._socket.addEventListener("message", this._handleMessage.bind(this));
this.socket.onopen = this._onOpen.bind(this); this._socket.onopen = this._onOpen.bind(this);
this.socket.onclose = () => { this._socket.onclose = () => {
this.socket = null; this._socket = null;
this.store.commit("setSessionId", ""); this._store.commit("setSessionId", "");
clearInterval(this._pingTimer);
this._pingTimer = null;
}; };
} }
@ -31,8 +44,8 @@ class LiveSession {
* @private * @private
*/ */
_send(command, params) { _send(command, params) {
if (this.socket) { if (this._socket && this._socket.readyState === 1) {
this.socket.send(JSON.stringify([command, params])); this._socket.send(JSON.stringify([command, params]));
} }
} }
@ -41,11 +54,22 @@ class LiveSession {
* @private * @private
*/ */
_onOpen() { _onOpen() {
if (this.isSpectator) { if (this._isSpectator) {
this._send("req", "gs"); this._send("req", "gs");
} else { } else {
this.sendGamestate(); this.sendGamestate();
} }
this._ping();
}
/**
* Send a ping message with player ID and ST flag.
* @private
*/
_ping() {
this._send("ping", [this._isSpectator, this._playerId]);
clearTimeout(this._pingTimer);
this._pingTimer = setTimeout(this._ping.bind(this), this._pingInterval);
} }
/** /**
@ -71,6 +95,13 @@ class LiveSession {
break; break;
case "player": case "player":
this._updatePlayer(params); this._updatePlayer(params);
break;
case "ping":
this._handlePing(params);
break;
case "bye":
this._handleBye(params);
break;
} }
} }
@ -79,7 +110,8 @@ class LiveSession {
* @param channel * @param channel
*/ */
connect(channel) { connect(channel) {
this.isSpectator = this.store.state.session.isSpectator; this._store.commit("setPlayerCount", 0);
this._isSpectator = this._store.state.session.isSpectator;
this._open(channel); this._open(channel);
} }
@ -87,9 +119,11 @@ class LiveSession {
* Close the current session, if any. * Close the current session, if any.
*/ */
disconnect() { disconnect() {
if (this.socket) { this._store.commit("setPlayerCount", 0);
this.socket.close(); if (this._socket) {
this.socket = null; this._send("bye", this._playerId);
this._socket.close();
this._socket = null;
} }
} }
@ -97,8 +131,8 @@ class LiveSession {
* Publish the current gamestate. * Publish the current gamestate.
*/ */
sendGamestate() { sendGamestate() {
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,
isDead: player.isDead, isDead: player.isDead,
isVoteless: player.isVoteless, isVoteless: player.isVoteless,
@ -113,8 +147,8 @@ class LiveSession {
: {}) : {})
})); }));
this._send("gs", { this._send("gs", {
gamestate: this.gamestate, gamestate: this._gamestate,
edition: this.store.state.edition edition: this._store.state.edition
}); });
} }
@ -125,16 +159,16 @@ class LiveSession {
* @private * @private
*/ */
_updateGamestate({ gamestate, edition }) { _updateGamestate({ gamestate, edition }) {
this.store.commit("setEdition", edition); this._store.commit("setEdition", edition);
const players = this.store.state.players.players; const players = this._store.state.players.players;
// adjust number of players // adjust number of players
if (players.length < gamestate.length) { if (players.length < gamestate.length) {
for (let x = players.length; x < gamestate.length; x++) { for (let x = players.length; x < gamestate.length; x++) {
this.store.commit("players/add", gamestate[x].name); this._store.commit("players/add", gamestate[x].name);
} }
} else if (players.length > gamestate.length) { } else if (players.length > gamestate.length) {
for (let x = players.length; x > gamestate.length; x--) { for (let x = players.length; x > gamestate.length; x--) {
this.store.commit("players/remove", x - 1); this._store.commit("players/remove", x - 1);
} }
} }
// update status for each player // update status for each player
@ -142,34 +176,34 @@ class LiveSession {
const player = players[x]; const player = players[x];
const { name, isDead, isVoteless, role } = state; const { name, isDead, isVoteless, role } = state;
if (player.name !== name) { if (player.name !== name) {
this.store.commit("players/update", { this._store.commit("players/update", {
player, player,
property: "name", property: "name",
value: name value: name
}); });
} }
if (player.isDead !== isDead) { if (player.isDead !== isDead) {
this.store.commit("players/update", { this._store.commit("players/update", {
player, player,
property: "isDead", property: "isDead",
value: isDead value: isDead
}); });
} }
if (player.isVoteless !== isVoteless) { if (player.isVoteless !== isVoteless) {
this.store.commit("players/update", { this._store.commit("players/update", {
player, player,
property: "isVoteless", property: "isVoteless",
value: isVoteless value: isVoteless
}); });
} }
if (role && player.role.id !== role.id) { if (role && player.role.id !== role.id) {
this.store.commit("players/update", { this._store.commit("players/update", {
player, player,
property: "role", property: "role",
value: role value: role
}); });
} else if (!role && player.role.team === "traveler") { } else if (!role && player.role.team === "traveler") {
this.store.commit("players/update", { this._store.commit("players/update", {
player, player,
property: "role", property: "role",
value: {} value: {}
@ -185,12 +219,12 @@ class LiveSession {
* @param value * @param value
*/ */
sendPlayer({ player, property, value }) { sendPlayer({ player, property, value }) {
if (this.isSpectator || property === "reminders") return; if (this._isSpectator || property === "reminders") return;
const index = this.store.state.players.players.indexOf(player); const index = this._store.state.players.players.indexOf(player);
if (property === "role") { if (property === "role") {
if (value.team && value.team === "traveler") { if (value.team && value.team === "traveler") {
// update local gamestate to remember this player as a traveler // update local gamestate to remember this player as a traveler
this.gamestate[index].role = { this._gamestate[index].role = {
id: player.role.id, id: player.role.id,
team: "traveler", team: "traveler",
name: player.role.name name: player.role.name
@ -198,10 +232,10 @@ class LiveSession {
this._send("player", { this._send("player", {
index, index,
property, property,
value: this.gamestate[index].role value: this._gamestate[index].role
}); });
} else if (this.gamestate[index].role) { } else if (this._gamestate[index].role) {
delete this.gamestate[index].role; delete this._gamestate[index].role;
this._send("player", { index, property, value: {} }); this._send("player", { index, property, value: {} });
} }
} else { } else {
@ -217,7 +251,7 @@ class LiveSession {
* @private * @private
*/ */
_updatePlayer({ index, property, value }) { _updatePlayer({ index, property, value }) {
const player = this.store.state.players.players[index]; const player = this._store.state.players.players[index];
if (!player) return; if (!player) return;
// special case where a player stops being a traveler // special case where a player stops being a traveler
if ( if (
@ -226,16 +260,47 @@ class LiveSession {
player.role.team === "traveler" player.role.team === "traveler"
) { ) {
// reset to an unknown role // reset to an unknown role
this.store.commit("players/update", { this._store.commit("players/update", {
player, player,
property: "role", property: "role",
value: {} value: {}
}); });
} else { } else {
// just update the player otherwise // just update the player otherwise
this.store.commit("players/update", { player, property, value }); this._store.commit("players/update", { player, property, value });
} }
} }
/**
* Handle a ping message by another player / storyteller
* @param isSpectator
* @param playerId
* @private
*/
_handlePing([isSpectator, playerId]) {
const now = new Date().getTime();
this._players[playerId] = now;
// 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];
}
}
this._store.commit("setPlayerCount", Object.keys(this._players).length);
if (!this._isSpectator && !isSpectator) {
alert("Another storyteller joined the session!");
}
}
/**
* Handle a player leaving the sessions
* @param playerId
* @private
*/
_handleBye(playerId) {
delete this._players[playerId];
this._store.commit("setPlayerCount", Object.keys(this._players).length);
}
} }
module.exports = store => { module.exports = store => {
@ -255,6 +320,7 @@ module.exports = store => {
break; break;
case "players/set": case "players/set":
case "players/swap": case "players/swap":
case "players/move":
case "players/clear": case "players/clear":
case "players/remove": case "players/remove":
case "players/add": case "players/add":

View File

@ -9,5 +9,6 @@ $editions:
'tb', 'tb',
'bmr', 'bmr',
'snv', 'snv',
'luf' true 'luf' true,
'custom' true
; ;