Merge pull request #113 from bra1n/custom-images

Support custom character icons / edition logos again with opt-in
This commit is contained in:
Steffen 2021-02-11 20:33:18 +01:00 committed by GitHub
commit 729f3981d6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 236 additions and 172 deletions

View File

@ -1,5 +1,11 @@
# Release Notes
### Version 2.8.0
- added hands-off live session support for homebrew / custom characters again!
- added custom image opt-in that will prevent any (potentially malicious / harmful) images from loading until a player manually allows them to
---
## Version 2.7.0
- added support for assigning duplicate characters to more than one player (like Legion)
- further live session bandwidth optimizations

2
package-lock.json generated
View File

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

View File

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

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

View File

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

View File

@ -23,7 +23,7 @@
>
<em>{{ nightOrder.get(player).first }}.</em>
<span v-if="player.role.firstNightReminder">{{
player.role.firstNightReminder | handleEmojis
player.role.firstNightReminder
}}</span>
</div>
<div
@ -32,7 +32,7 @@
>
<em>{{ nightOrder.get(player).other }}.</em>
<span v-if="player.role.otherNightReminder">{{
player.role.otherNightReminder | handleEmojis
player.role.otherNightReminder
}}</span>
</div>
@ -165,8 +165,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,9 +231,6 @@ export default {
isSwap: false
};
},
filters: {
handleEmojis: text => text.replace(/:([^: ]+?):/g, "").replace(/ •/g, "\n•")
},
methods: {
toggleStatus() {
if (this.grimoire.isPublic) {

View File

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

View File

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

View File

@ -41,8 +41,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="reminder" v-if="role.firstNightReminder">
@ -61,8 +66,13 @@
class="icon"
v-if="role.id"
:style="{
backgroundImage: `url(${role.image ||
require('../../assets/icons/' + role.id + '.png')})`
backgroundImage: `url(${
role.image && grimoire.isImageOptIn
? role.image
: require('../../assets/icons/' +
(role.imageAlt || role.id) +
'.png')
})`
}"
></span>
<span class="name">
@ -107,14 +117,21 @@ export default {
name: "Minion info",
firstNight: 2,
team: "minion",
players: this.players.filter(p => p.role.team === "minion")
players: this.players.filter(p => p.role.team === "minion"),
firstNightReminder:
"• If more than one Minion, they all make eye contact with each other. " +
"• Show the “This is the Demon” card. Point to the Demon."
},
{
id: "evil",
name: "Demon info & bluffs",
firstNight: 4,
team: "demon",
players: this.players.filter(p => p.role.team === "demon")
players: this.players.filter(p => p.role.team === "demon"),
firstNightReminder:
"• Show the “These are your minions” card. Point to each Minion. " +
"• Show the “These characters are not in play” card. Show 3 character tokens of good " +
"characters not in play."
}
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -354,12 +354,10 @@ class LiveSession {
const { edition } = this._store.state;
let roles;
if (!edition.isOfficial) {
roles = Array.from(this._store.state.roles.keys());
roles = this._store.getters.customRolesStripped;
}
this._sendDirect(playerId, "edition", {
edition: edition.isOfficial
? { id: edition.id }
: Object.assign({}, edition, { logo: "" }),
edition: edition.isOfficial ? { id: edition.id } : edition,
...(roles ? { roles } : {})
});
}
@ -374,10 +372,7 @@ 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 => {