Initial v2
@@ -1,30 +1,9 @@
|
||||
# Elm
|
||||
/elm-stuff/
|
||||
# IDE files.
|
||||
.idea/
|
||||
|
||||
# NodeJS
|
||||
/node_modules/
|
||||
# Generated files.
|
||||
dist/
|
||||
|
||||
# Activator
|
||||
/logs/
|
||||
/target/
|
||||
/project/project/target
|
||||
/project/target/
|
||||
/project/sbt-ui.sbt
|
||||
/project/play-fork-run.sbt
|
||||
/RUNNING_PID
|
||||
|
||||
# IntelliJ
|
||||
/.idea/
|
||||
|
||||
# Eclipse
|
||||
/.classpath
|
||||
/.project
|
||||
/.settings
|
||||
|
||||
# SBT
|
||||
/.sbtserver
|
||||
/project/.sbtserver
|
||||
/project/.sbtserver.lock
|
||||
|
||||
# Compiled from elm.
|
||||
/public/javascripts/MassiveDecks.js
|
||||
# Package management cruft.
|
||||
elm-stuff/
|
||||
node_modules/
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
language: scala
|
||||
scala:
|
||||
- 2.11.8
|
||||
jdk:
|
||||
- oraclejdk8
|
||||
language: sh
|
||||
|
||||
services:
|
||||
- docker
|
||||
|
||||
sudo: required
|
||||
|
||||
env:
|
||||
global:
|
||||
- VERSION=${TRAVIS_TAG:-${TRAVIS_COMMIT}-dev}
|
||||
matrix:
|
||||
- COMPONENT=server
|
||||
- COMPONENT=client
|
||||
|
||||
before_install:
|
||||
- cd ${COMPONENT}
|
||||
|
||||
install: true
|
||||
|
||||
script:
|
||||
- docker build -t massivedecks/${COMPONENT}:${VERSION} --build-arg VCS_REF="${TRAVIS_COMMIT}" --build-arg BUILD_DATE="$(date --rfc-3339=seconds)" --build-arg VERSION="${VERSION}" .
|
||||
|
||||
deploy:
|
||||
provider: script
|
||||
script: bash ../docker_push "${COMPONENT}" "${VERSION}"
|
||||
on:
|
||||
branch: v2
|
||||
|
||||
@@ -1,40 +1,42 @@
|
||||
# Massive Decks [](https://travis-ci.org/Lattyware/massivedecks)
|
||||
# Massive Decks [](https://travis-ci.org/Lattyware/massivedecks)
|
||||
|
||||
[](https://heroku.com/deploy?template=https://github.com/Lattyware/massivedecks)
|
||||
Massive Decks is a comedy party game based on [Cards against Humanity][cah]. The game is open source software available
|
||||
under [the AGPLv3 license][license].
|
||||
|
||||
Massive Decks is a web game based on the excellent [Cards against Humanity][cah] - a party game where you play white cards to try and produce the most amusing outcome when combined with the given black card. The game can be played on phone or computer and uses [Cardcast decks][cardcast].
|
||||
|
||||
Play the game at:
|
||||
**[https://massivedecks.herokuapp.com/][massivedecks]**
|
||||
|
||||
(This is hosted on a service that will automatically sleep if it's unused for a while - it may take a little time to load in those cases, but should be fine after the initial load.)
|
||||
|
||||
[][massivedecks]
|
||||
|
||||
If you have any problems with the game, please [raise an issue][issue]. If you would like to help develop the game, check [the wiki][wiki] for how to get set up and help.
|
||||
|
||||
Massive Decks is also inspired by:
|
||||
* [CardCast](https://www.cardcastgame.com/) - a great app that allows you to play on a ChromeCast.
|
||||
* [Pretend You're Xyzzy](http://pretendyoure.xyz/zy/) - a web game where you can jump in with people you don't know.
|
||||
|
||||
This is an open source game developed in [Elm][elm] for the client and [Scala][scala] for the server.
|
||||
|
||||
We also use:
|
||||
* [CardCast](https://www.cardcastgame.com/)'s APIs for getting decks of cards (you can go there to make your own!).
|
||||
* [The Play framework](https://www.playframework.com/)
|
||||
* [Less](http://lesscss.org/)
|
||||
* [Font Awesome](https://fortawesome.github.io/Font-Awesome/)
|
||||
* [MUI](https://www.muicss.com/)
|
||||
|
||||
Massive Decks is under [the AGPLv3 license][license]. The game concept 'Cards against Humanity' is used under a
|
||||
[Creative Commons BY-NC-SA 2.0 license][cah-license] granted by [Cards against Humanity][cah].
|
||||
|
||||
[massivedecks]: https://massivedecks.herokuapp.com/
|
||||
[cah]: https://cardsagainsthumanity.com/
|
||||
[cardcast]: https://www.cardcastgame.com/browse
|
||||
[license]: https://github.com/Lattyware/massivedecks/blob/master/LICENSE
|
||||
|
||||
## About
|
||||
|
||||
This is a bleeding-edge development version of the game, a complete rewrite from the old version. It is not currently
|
||||
ready for release, and unless you are interested in developing the game, you should stick with v1 for now.
|
||||
|
||||
## Contributing
|
||||
|
||||
If you have any problems with the game, please [raise an issue][issue]. If you would like to help develop the game,
|
||||
check [the wiki][wiki] for how to get started.
|
||||
|
||||
[issue]: https://github.com/Lattyware/massivedecks/issues/new
|
||||
[wiki]: https://github.com/Lattyware/massivedecks/wiki
|
||||
[elm]: http://elm-lang.org
|
||||
[scala]: http://www.scala-lang.org/
|
||||
[license]: https://github.com/Lattyware/massivedecks/blob/master/LICENSE
|
||||
|
||||
## Credits
|
||||
|
||||
### Maintainers
|
||||
|
||||
Massive Decks is maintained by [Reread Games][reread].
|
||||
|
||||
[reread]: https://www.rereadgames.com/
|
||||
|
||||
### Inspiration
|
||||
|
||||
The 'Cards against Humanity' game concept is used under a [Creative Commons BY-NC-SA 2.0 license][cah-license] granted
|
||||
by [Cards against Humanity][cah].
|
||||
|
||||
[cah-license]: https://creativecommons.org/licenses/by-nc-sa/2.0/
|
||||
|
||||
Massive Decks is also inspired by:
|
||||
* [CardCast][cardcast] - an app you to play on a ChromeCast.
|
||||
* [Pretend You're Xyzzy][xyzzy] - a web game where you can jump in with people you don't know.
|
||||
|
||||
[cardcast]: https://www.cardcastgame.com/
|
||||
[xyzzy]: http://pretendyoure.xyz/zy/
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"name": "Massive Decks",
|
||||
"description": "A party game where you play white cards to try and produce the most amusing outcome when combined with the given black card.",
|
||||
"keywrods": [
|
||||
"game",
|
||||
"party",
|
||||
"comedy"
|
||||
],
|
||||
"repository": "https://github.com/Lattyware/massivedecks",
|
||||
"env": {
|
||||
"MD_HOST": {
|
||||
"description": "The hostname Massive Decks will be accessible at.",
|
||||
"value": "yourappname.herokuapp.com"
|
||||
},
|
||||
"MD_PATH": {
|
||||
"description": "The path Massive Decks will be accessible at.",
|
||||
"value": "/"
|
||||
},
|
||||
"MD_PROTOCOL": {
|
||||
"description": "The protocol Massive Decks will be accessible on.",
|
||||
"value": "https"
|
||||
},
|
||||
"APPLICATION_SECRET": {
|
||||
"description": "A random secret for security confirmation in the application.",
|
||||
"generator": "secret"
|
||||
},
|
||||
"ELM_VERSION": "0.18",
|
||||
"ELM_COMPILE": "elm make client/src/MassiveDecks.elm --output public/javascripts/MassiveDecks.js"
|
||||
},
|
||||
"buildpacks": [
|
||||
{
|
||||
"url": "https://github.com/ianpurvis/heroku-buildpack-version"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/srid/heroku-buildpack-elm"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/heroku/heroku-buildpack-scala"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
@import "variables.less";
|
||||
|
||||
.game-code {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
#config {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#config-content {
|
||||
max-width: 500px;
|
||||
margin: 15px auto;
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.decks {
|
||||
margin-bottom: 0px;
|
||||
|
||||
table {
|
||||
td {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-with-button {
|
||||
display: flex;
|
||||
|
||||
& > .mui-textfield {
|
||||
margin: 0px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.house-rule {
|
||||
div {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
#start-game {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
& > span {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
@import "variables.less";
|
||||
|
||||
body {
|
||||
background-image: url("/images/bgnoise_lg.png");
|
||||
background-color: @background-color;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: @card-width;
|
||||
height: @card-height;
|
||||
|
||||
@media (min-width: @enhance-width) {
|
||||
width: @card-width-enhanced;
|
||||
height: @card-width-enhanced;
|
||||
}
|
||||
|
||||
margin: 10px;
|
||||
padding: 15px;
|
||||
font-weight: bold;
|
||||
word-wrap: break-word;
|
||||
border-radius: 10px;
|
||||
|
||||
& > div {
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.play-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
|
||||
.action-menu {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
right: 5px;
|
||||
}
|
||||
|
||||
.round-area {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
.others-picked {
|
||||
.card {
|
||||
position: absolute;
|
||||
z-index: -10;
|
||||
transition: transform 1s, left 1s, right 1s, top 1s ease-out
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#advice {
|
||||
max-width: 500px;
|
||||
margin: 0px 25px 0px 25px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#cards {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 25px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#logo {
|
||||
display: block;
|
||||
margin: 25px auto 0px auto;
|
||||
width: 150px;
|
||||
|
||||
img {
|
||||
width: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
.call {
|
||||
background-color: #000000;
|
||||
color: #ffffff;
|
||||
flex-shrink: 0;
|
||||
|
||||
transform: rotate(4deg);
|
||||
|
||||
& > .call-text {
|
||||
& > .slot {
|
||||
text-decoration: underline;
|
||||
|
||||
&:empty {
|
||||
display: inline-block;
|
||||
height: 1.2em;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
min-width: 40px;
|
||||
max-width: 80px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.response {
|
||||
background-color: #ffffff;
|
||||
color: #000000;
|
||||
|
||||
&.picked {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-spot {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
ol {
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
list-style-type: none;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
|
||||
& > li {
|
||||
display: inline-block;
|
||||
white-space: normal;
|
||||
|
||||
.card {
|
||||
margin: 10px 5px 10px 5px;
|
||||
}
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-left: -(@card-width * @card-overlap);
|
||||
@media (min-width: @enhance-width) {
|
||||
margin-left: -(@card-width-enhanced * @card-overlap);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#status-card {
|
||||
transform: rotate(-7deg);
|
||||
}
|
||||
|
||||
#message-card {
|
||||
transform: rotate(12deg);
|
||||
}
|
||||
|
||||
#version {
|
||||
margin: 20px;
|
||||
}
|
||||
@@ -1,533 +0,0 @@
|
||||
@import "variables.less";
|
||||
|
||||
.card {
|
||||
width: @card-width;
|
||||
height: @card-height;
|
||||
|
||||
@media (min-width: @enhance-width) {
|
||||
width: @card-width-enhanced;
|
||||
height: @card-width-enhanced;
|
||||
}
|
||||
|
||||
margin: 10px;
|
||||
padding: 15px;
|
||||
font-weight: bold;
|
||||
word-wrap: break-word;
|
||||
border-radius: 10px;
|
||||
|
||||
& > div {
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.play-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
|
||||
.action-menu {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
right: 5px;
|
||||
}
|
||||
|
||||
.round-area {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
.others-picked {
|
||||
.card {
|
||||
position: absolute;
|
||||
z-index: -10;
|
||||
transition: transform 1s, left 1s, right 1s, top 1s ease-out
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.call {
|
||||
background-color: #000000;
|
||||
color: #ffffff;
|
||||
flex-shrink: 0;
|
||||
|
||||
& > .call-text {
|
||||
& > .slot {
|
||||
text-decoration: underline;
|
||||
|
||||
&:empty {
|
||||
display: inline-block;
|
||||
height: 1.2em;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
min-width: 40px;
|
||||
max-width: 80px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.response {
|
||||
background-color: #ffffff;
|
||||
color: #000000;
|
||||
|
||||
&.picked {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-spot {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.winner {
|
||||
max-width: 500px;
|
||||
margin: 15px auto;
|
||||
padding: 5px 25px 5px 25px;
|
||||
|
||||
& > h1 {
|
||||
margin: 5px;
|
||||
|
||||
& > .fa {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: #FFD700;
|
||||
}
|
||||
}
|
||||
|
||||
& > h2 {
|
||||
margin: 5px;
|
||||
|
||||
&::first-letter {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
& > h3 {
|
||||
margin: 5px;
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
#next-round-button {
|
||||
display: block;
|
||||
margin: 10px auto;
|
||||
}
|
||||
|
||||
.picked {
|
||||
ol {
|
||||
display: inline;
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
list-style-type: none;
|
||||
white-space: nowrap;
|
||||
|
||||
& > li {
|
||||
display: inline-block;
|
||||
white-space: normal;
|
||||
|
||||
.card {
|
||||
margin: 10px 5px 10px 5px;
|
||||
}
|
||||
|
||||
& > .response {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
|
||||
& > .withdraw-button {
|
||||
position: absolute;
|
||||
left: -4px;
|
||||
bottom: -9px;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-left: -(@card-width * @card-overlap);
|
||||
@media (min-width: @enhance-width) {
|
||||
margin-left: -(@card-width-enhanced * @card-overlap);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.play-button {
|
||||
display: inline;
|
||||
margin-left: -45px;
|
||||
}
|
||||
}
|
||||
|
||||
ol.considering {
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
list-style-type: none;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
|
||||
& > li {
|
||||
display: inline-block;
|
||||
white-space: normal;
|
||||
|
||||
.card {
|
||||
margin: 10px 5px 10px 5px;
|
||||
}
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-left: -(@card-width * @card-overlap);
|
||||
@media (min-width: @enhance-width) {
|
||||
margin-left: -(@card-width-enhanced * @card-overlap);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.choose-button {
|
||||
margin-left: -45px;
|
||||
}
|
||||
|
||||
ol.played {
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
list-style-type: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
& > li {
|
||||
display: inline-block;
|
||||
|
||||
& > ol {
|
||||
margin: 0px 10px 0px 10px;
|
||||
padding: 0px;
|
||||
list-style-type: none;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
|
||||
& > li {
|
||||
white-space: normal;
|
||||
display: inline-block;
|
||||
|
||||
.card {
|
||||
margin: 10px 5px 10px 5px;
|
||||
}
|
||||
}
|
||||
|
||||
& > li > .response {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hand {
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.scores-toggle {
|
||||
flex: none;
|
||||
width: 50px;
|
||||
font-size: 20px;
|
||||
margin: 0px 5px;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
|
||||
color: #ffffff;
|
||||
background-color: #555555;
|
||||
|
||||
padding: 5px;
|
||||
border-radius: 2px;
|
||||
|
||||
line-height: normal;
|
||||
|
||||
position: relative;
|
||||
|
||||
transition: opacity 1s;
|
||||
|
||||
white-space: nowrap;
|
||||
|
||||
&.hide {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:after {
|
||||
right: 100%;
|
||||
top: 50%;
|
||||
border: solid transparent;
|
||||
content: " ";
|
||||
height: 0;
|
||||
width: 0;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border-color: rgba(85, 85, 85, 0);
|
||||
border-right-color: #555555;
|
||||
border-width: 10px;
|
||||
margin-top: -10px;
|
||||
}
|
||||
|
||||
@media (min-width: @enhance-width) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.hide-scores {
|
||||
#scores {
|
||||
transform: translate(0px);
|
||||
}
|
||||
|
||||
#warning-drawer {
|
||||
@media (min-width: @enhance-width) {
|
||||
left: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block !important;
|
||||
}
|
||||
}
|
||||
|
||||
#scores {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: @sidebar-width;
|
||||
left: -@sidebar-width;
|
||||
overflow: auto;
|
||||
z-index: 2;
|
||||
background-color: #fff;
|
||||
transition: transform @sidebar-transition-time;
|
||||
@media (min-width: @enhance-width) {
|
||||
transform: translate(@sidebar-width);
|
||||
}
|
||||
|
||||
&.shownAsOverlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0px 5px 0px 5px;
|
||||
}
|
||||
|
||||
&.active {
|
||||
transform: translate(@sidebar-width) !important;
|
||||
}
|
||||
|
||||
& > table {
|
||||
table-layout: fixed;
|
||||
|
||||
& > thead > tr > th {
|
||||
text-align: center;
|
||||
padding: 5px;
|
||||
|
||||
&.state, &.score {
|
||||
width: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
& > tbody > tr {
|
||||
padding: 0px;
|
||||
|
||||
&.left {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
&.you {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
&.skipping {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&.czar > td.name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
& > td {
|
||||
padding: 0px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.state, &.score {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#info-bar {
|
||||
width: 100%;
|
||||
background-color: #cccccc;
|
||||
padding: 3px 10px 3px 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#warning-drawer {
|
||||
position: fixed;
|
||||
bottom: 0px;
|
||||
left: 0;
|
||||
right: 0px;
|
||||
|
||||
z-index: 100;
|
||||
|
||||
transition: opacity 1s, left @sidebar-transition-time;
|
||||
|
||||
max-height: 75%;
|
||||
|
||||
& > .contents {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
& > .toggle {
|
||||
float: right;
|
||||
margin: 5px;
|
||||
background-color: #FFCC00;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
& > .top {
|
||||
clear: both;
|
||||
width: 100%;
|
||||
height: 0px;
|
||||
background-color: #FFCC00;
|
||||
}
|
||||
|
||||
&.shut {
|
||||
& > .top {
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
& > .contents {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (min-width: @enhance-width) {
|
||||
left: @sidebar-width;
|
||||
}
|
||||
|
||||
.notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
justify-content: space-between;
|
||||
|
||||
background-color: #FFCC00;
|
||||
padding: 2px;
|
||||
|
||||
& > h3 {
|
||||
margin: 2px 5px;
|
||||
}
|
||||
|
||||
& > .actions {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
& > button {
|
||||
margin: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#history {
|
||||
position: relative;
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
|
||||
h1 {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
ul {
|
||||
display: block;
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
li {
|
||||
display: block;
|
||||
}
|
||||
|
||||
button {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
right: 5px;
|
||||
}
|
||||
|
||||
.round {
|
||||
margin: 0px 0px 20px 10px;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0px 10px 0px 0px;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin: 0px 5px 5px 5px;
|
||||
}
|
||||
|
||||
.who {
|
||||
display: block;
|
||||
width: @card-width;
|
||||
text-align: center;
|
||||
margin: 0px 5px 0px 5px;
|
||||
height: 25px;
|
||||
line-height: 25px;
|
||||
|
||||
@media (min-width: @enhance-width) {
|
||||
width: @card-width-enhanced;
|
||||
}
|
||||
}
|
||||
|
||||
.who.won {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.responses {
|
||||
background-color: #dddddd;
|
||||
margin: 0px 10px 0px 0px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.round, .plays, .responses {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
@import "variables.less";
|
||||
@import "start.less";
|
||||
@import "config.less";
|
||||
@import "lobby.less";
|
||||
|
||||
body {
|
||||
background-image: url("/images/bgnoise_lg.png");
|
||||
background-color: @background-color;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
|
||||
font-family: "Helvetica Neue", "Helvetica", "Arial", sans-serif;
|
||||
}
|
||||
|
||||
#content-wrapper {
|
||||
min-height: 100%;
|
||||
|
||||
margin-left: 0px;
|
||||
transition: margin-left @sidebar-transition-time;
|
||||
|
||||
@media (min-width: @enhance-width) {
|
||||
margin-left: @sidebar-width;
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@media (min-width: @enhance-width) {
|
||||
left: @sidebar-width;
|
||||
}
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
transition: left @sidebar-transition-time;
|
||||
|
||||
& > div {
|
||||
width: 100%;
|
||||
|
||||
& > div {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 0px;
|
||||
|
||||
& > .score-buttons {
|
||||
position: relative;
|
||||
|
||||
& > .badge {
|
||||
position:absolute;
|
||||
top: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu {
|
||||
button {
|
||||
flex: none;
|
||||
width: 50px;
|
||||
font-size: 20px;
|
||||
margin: 0px 5px;
|
||||
padding: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.hide-scores {
|
||||
@media (min-width: @enhance-width) {
|
||||
header {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
#content-wrapper {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#error-panel {
|
||||
display: block;
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
list-style-type: none;
|
||||
position: fixed;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
max-height: 50%;
|
||||
overflow: auto;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.error {
|
||||
position: relative;
|
||||
display: block;
|
||||
border: 1px solid #ff0000;
|
||||
background-color: #ffaaaa;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
width: 200px;
|
||||
text-align: center;
|
||||
margin: 5px;
|
||||
|
||||
.mui-divider {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
h5 {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
& > div > a {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 10px;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
p {
|
||||
text-align: left;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.mui-divider {
|
||||
margin: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
#mui-overlay {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-content: flex-start;
|
||||
align-items: flex-start;
|
||||
|
||||
.overlay {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.close-link {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay {
|
||||
display: none;
|
||||
|
||||
max-width: 500px;
|
||||
margin: 20px;
|
||||
|
||||
& input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.clear {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.input-error {
|
||||
display: block;
|
||||
color: #ff0000;
|
||||
width: 90%;
|
||||
margin: 0px auto 0px auto;
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
@import "variables.less";
|
||||
@start-width: 300px;
|
||||
@degrade-fork-banner: (@start-width + 100px);
|
||||
|
||||
#start-screen {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#start-screen-content {
|
||||
max-width: @start-width;
|
||||
margin: 15px auto;
|
||||
|
||||
h1 {
|
||||
width: 100%;
|
||||
margin: 0px;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
padding: 0px 0px 10px 0px;
|
||||
margin: 0px 0px 10px 0px;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
margin: 15px auto 0px auto;
|
||||
}
|
||||
|
||||
.rejoin {
|
||||
text-align: center;
|
||||
padding: 0px 0px 10px 0px;
|
||||
|
||||
ul {
|
||||
display: inline;
|
||||
list-style: none;
|
||||
padding: 0px;
|
||||
|
||||
li {
|
||||
display: inline;
|
||||
|
||||
&:not(:last-of-type)::after {
|
||||
content: ", "
|
||||
}
|
||||
|
||||
&:nth-last-of-type(2)::after {
|
||||
content: " or "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mui-textfield {
|
||||
width: 90%;
|
||||
margin: 10px auto 0px auto;
|
||||
}
|
||||
|
||||
& > .mui-divider {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.about-link {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
margin: 20px 0px 0px 0px;
|
||||
padding: 10px 0px 0px 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.nickname-entry {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
#forkongithub {
|
||||
margin-top: 5px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
|
||||
@media (min-width: @degrade-fork-banner) {
|
||||
margin-top: 0px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
height: 150px;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
div {
|
||||
padding: 2px 0;
|
||||
background-color: #333333;
|
||||
background-image: linear-gradient(to bottom, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.15));
|
||||
box-shadow: 0 2px 3px 0 rgba(0, 0, 0, 0.5);
|
||||
font: 700 13px "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
z-index: 9999;
|
||||
pointer-events: auto;
|
||||
|
||||
@media (min-width: @degrade-fork-banner) {
|
||||
position: absolute;
|
||||
top: 42px;
|
||||
right: -43px;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
a {
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
text-shadow: 0 -1px rgba(0, 0, 0, 0.5);
|
||||
text-align: center;
|
||||
|
||||
width: 100%;
|
||||
line-height: 20px;
|
||||
|
||||
display: inline-block;
|
||||
padding: 2px 0;
|
||||
|
||||
border-width: 1px 0;
|
||||
border-style: dotted;
|
||||
border-color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.7);
|
||||
|
||||
@media (min-width: @degrade-fork-banner) {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
footer {
|
||||
max-width: @start-width;
|
||||
margin: 30px auto;
|
||||
padding: 10px;
|
||||
|
||||
text-align: center;
|
||||
font-size: 0.8em;
|
||||
|
||||
img {
|
||||
height: 50px;
|
||||
margin: 0px 0px 10px 0px;
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
margin: 0px;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
@background-color: #e5e5e5;
|
||||
|
||||
@enhance-width: 768px; // If changed, should be changed in the elm (Lobby.elm) too.
|
||||
@sidebar-width: 200px;
|
||||
@sidebar-transition-time: 0.2s;
|
||||
|
||||
@card-width: 135px;
|
||||
@card-height: 160px;
|
||||
|
||||
@card-width-enhanced: 180px;
|
||||
@card-height-enhanced: 180px;
|
||||
|
||||
@card-overlap: 0.80;
|
||||
@@ -1,43 +0,0 @@
|
||||
package massivedecks
|
||||
|
||||
import javax.inject.Inject
|
||||
|
||||
import play.api.Configuration
|
||||
import play.api.mvc.RequestHeader
|
||||
|
||||
object Config {
|
||||
/**
|
||||
* Factory for dependency injection.
|
||||
*/
|
||||
class Factory @Inject()(base: Configuration) {
|
||||
def apply(request: RequestHeader) = new Config(base, request)
|
||||
}
|
||||
}
|
||||
class Config(base: Configuration, request: RequestHeader) {
|
||||
def getString(key: String) = base.getString(key)
|
||||
|
||||
def requestProtocol = request.headers.get("X-Forwarded-Proto") match {
|
||||
case Some(proto) => proto
|
||||
case None => if (request.secure) { "https" } else { "http" }
|
||||
}
|
||||
|
||||
def protocol = canonicalProtocol.getOrElse(requestProtocol)
|
||||
def host = canonicalHost.getOrElse(request.host)
|
||||
def path = canonicalPath.getOrElse("/")
|
||||
|
||||
def url = s"$protocol://$host$path"
|
||||
|
||||
def canonicalProtocol = getString("md_protocol")
|
||||
def canonicalHost = getString("md_host")
|
||||
def canonicalPath = getString("md_path")
|
||||
|
||||
def requestIsCanonical = (canonicalProtocol.forall(proto => proto == requestProtocol)
|
||||
&& canonicalHost.forall(host => host == request.host)
|
||||
&& canonicalPath.forall(path => request.path.startsWith(path)))
|
||||
|
||||
def canonicalUrlTo(path: String) = s"$protocol://$host$path"
|
||||
|
||||
def version = getString("md_release_version").getOrElse {
|
||||
getString("md_git_version").map(v => s"${v.take(7)}-dev").getOrElse("")
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package massivedecks
|
||||
|
||||
import com.google.inject.AbstractModule
|
||||
import massivedecks.stores.{InMemoryStore, LobbyStore}
|
||||
|
||||
/**
|
||||
* Guice module for the application.
|
||||
*/
|
||||
class CoreModule extends AbstractModule {
|
||||
def configure = {
|
||||
bind(classOf[LobbyStore]).to(classOf[InMemoryStore])
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package massivedecks
|
||||
|
||||
import scala.concurrent.duration.FiniteDuration
|
||||
import scala.concurrent.{Await, Future, Promise}
|
||||
import scala.util.Try
|
||||
|
||||
/**
|
||||
* Utility functions.
|
||||
*/
|
||||
object Util {
|
||||
|
||||
/**
|
||||
* Wait for the given amount of time.
|
||||
*
|
||||
* @param duration The time to wait for.
|
||||
* @return A future to wait on.
|
||||
*/
|
||||
def wait(duration: FiniteDuration): Try[Future[Nothing]] = Try(Await.ready(Promise().future, duration))
|
||||
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package massivedecks.cardcast
|
||||
|
||||
import javax.inject.Inject
|
||||
|
||||
import scala.collection.mutable.ListBuffer
|
||||
import scala.concurrent.duration._
|
||||
import scala.concurrent.{Await, ExecutionContext, Future, Promise}
|
||||
import scala.util.Try
|
||||
|
||||
import play.api.cache._
|
||||
import play.api.libs.json.JsValue
|
||||
import play.api.libs.ws.WSClient
|
||||
import massivedecks.exceptions.BadRequestException
|
||||
import massivedecks.models.{Errors, Game}
|
||||
import massivedecks.models.cardcast.CardcastDeck
|
||||
|
||||
class CardcastAPI @Inject() (@NamedCache("cardcast") cache: CacheApi, ws: WSClient) (implicit ec: ExecutionContext) {
|
||||
private val apiUrl: String = "https://api.cardcastgame.com/v1"
|
||||
|
||||
private def deckUrl(id: String): String = {
|
||||
BadRequestException.verify(id.length > 0, Errors.DeckNotFound())
|
||||
s"$apiUrl/decks/$id"
|
||||
}
|
||||
private def cardsUrl(id: String): String = s"${deckUrl(id)}/cards"
|
||||
|
||||
/**
|
||||
* Get a future that either returns the deck or blows up after 10 seconds of waiting on Cardcast.
|
||||
*
|
||||
* @param id The id of the deck to get.
|
||||
* @return See above.
|
||||
*/
|
||||
def deck(id: String): Future[CardcastDeck] = cache.get(id).map(Future.successful).getOrElse {
|
||||
requestDeck(id)
|
||||
}
|
||||
|
||||
private def requestDeck(id: String): Future[CardcastDeck] = {
|
||||
val deckInfo = requestJson(deckUrl(id)).map(parseInfo)
|
||||
val cards = requestJson(cardsUrl(id)).map(parseCards)
|
||||
|
||||
val futureDeck = for {
|
||||
name <- deckInfo
|
||||
(calls, responses) <- cards
|
||||
} yield CardcastDeck(id, name, calls, responses)
|
||||
|
||||
futureDeck.onSuccess { case deck =>
|
||||
cache.set(id, deck, CardCastAPI.cacheDuration)
|
||||
}
|
||||
|
||||
futureDeck
|
||||
}
|
||||
|
||||
private def requestJson(url: String): Future[JsValue] =
|
||||
ws.url(url).get().map(response => response.json)
|
||||
|
||||
private def parseInfo(deckInfo: JsValue): String = {
|
||||
Try(
|
||||
(deckInfo \ "name").validate[String].get
|
||||
).getOrElse {
|
||||
parseError(deckInfo)
|
||||
}
|
||||
}
|
||||
|
||||
private def parseCards(cards: JsValue): (List[Game.Call], List[Game.Response]) = {
|
||||
Try({
|
||||
val calls = ListBuffer[Game.Call]()
|
||||
val responses = ListBuffer[Game.Response]()
|
||||
for (call: JsValue <- (cards \ "calls").validate[List[JsValue]].get) {
|
||||
calls += Game.Call((call \ "id").validate[String].get, (call \ "text").validate[List[String]].get)
|
||||
}
|
||||
for (response: JsValue <- (cards \ "responses").validate[List[JsValue]].get) {
|
||||
responses += Game.Response((response \ "id").validate[String].get, (response \ "text").validate[List[String]].get.head)
|
||||
}
|
||||
(calls.toList, responses.toList)
|
||||
}).getOrElse {
|
||||
parseError(cards)
|
||||
}
|
||||
}
|
||||
|
||||
private def parseError[T](error: JsValue): T = {
|
||||
(error \ "id").validate[String].asOpt match {
|
||||
case Some("not_found") => throw BadRequestException(Errors.DeckNotFound())
|
||||
case Some(errorName) => throw new Exception(s"Cardcast gave an unknown error ('$errorName') when trying to retrieve the deck.")
|
||||
case None => throw new Exception(s"Cardcast gave an error that couldn't be parsed when trying to retrieve the deck.")
|
||||
}
|
||||
}
|
||||
}
|
||||
object CardCastAPI {
|
||||
val cacheDuration = 15.minutes
|
||||
|
||||
def wait(duration: FiniteDuration): Try[Future[Nothing]] = Try(Await.ready(Promise().future, duration))
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
package massivedecks.controllers
|
||||
|
||||
import javax.inject.Inject
|
||||
|
||||
import scala.concurrent.{ExecutionContext, Future}
|
||||
import scala.util.{Failure, Success, Try}
|
||||
|
||||
import akka.stream.scaladsl.{Flow, Sink, Source}
|
||||
import play.api.libs.json.{JsResult, JsValue, Json}
|
||||
import play.api.mvc._
|
||||
import massivedecks.exceptions._
|
||||
import massivedecks.models.Errors
|
||||
import massivedecks.models.Game.Formatters._
|
||||
import massivedecks.models.Lobby.Formatters._
|
||||
import massivedecks.models.Lobby.GameCodeAndSecret
|
||||
import massivedecks.models.Player
|
||||
import massivedecks.models.Player.Formatters._
|
||||
import massivedecks.stores.LobbyStore
|
||||
import play.api.libs.streams.Streams
|
||||
|
||||
class API @Inject()(store: LobbyStore) (implicit context: ExecutionContext) extends Controller {
|
||||
|
||||
def createLobby() = Action(parse.json) { request: Request[JsValue] =>
|
||||
wrap {
|
||||
val name = (request.body \ "name").validate[String].getOrElse(throw BadRequestException(Errors.InvalidName()))
|
||||
val gameCode = store.newLobby(name)
|
||||
store.performInLobby(gameCode) { lobby =>
|
||||
Json.toJson(GameCodeAndSecret(lobby.gameCode, lobby.owner))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def notifications(gameCode: String) = WebSocket.acceptOrResult[String, String] { requestHeader => Future {
|
||||
Try {
|
||||
store.performInLobby(gameCode) { lobby =>
|
||||
lobby.register()
|
||||
}
|
||||
} match {
|
||||
case Success((iteratee, enumerator)) =>
|
||||
val (sub, _) = Streams.iterateeToSubscriber(iteratee)
|
||||
val pub = Streams.enumeratorToPublisher(enumerator)
|
||||
Right(Flow.fromSinkAndSource(Sink.fromSubscriber(sub), Source.fromPublisher(pub)))
|
||||
|
||||
case Failure(error) =>
|
||||
error match {
|
||||
case exception: RequestException =>
|
||||
Left(Status(exception.statusCode)(exception.details.toJson()))
|
||||
case _ =>
|
||||
throw error
|
||||
}
|
||||
}
|
||||
} }
|
||||
|
||||
def getLobby(gameCode: String) = Action {
|
||||
wrap(store.readFromLobby(gameCode) { lobby =>
|
||||
Json.toJson(lobby.lobby)
|
||||
})
|
||||
}
|
||||
|
||||
def command(gameCode: String) = Action(parse.json) { request: Request[JsValue] =>
|
||||
wrap {
|
||||
store.performInLobby(gameCode) { lobby =>
|
||||
val secret = (request.body \ "secret").validate[Player.Secret].getOrElse(throw ForbiddenException(Errors.BadlyFormedSecret()))
|
||||
extractCommandArgument((request.body \ "command").validate[String]) match {
|
||||
case "addDeck" => lobby.addDeck(secret, extractCommandArgument((request.body \ "playCode").validate[String]))
|
||||
case "newGame" => lobby.newGame(secret)
|
||||
case "play" => lobby.play(secret, extractCommandArgument((request.body \ "ids").validate[List[String]]))
|
||||
case "choose" => lobby.choose(secret, extractCommandArgument((request.body \ "winner").validate[Int]))
|
||||
case "getLobbyAndHand" => lobby.getLobbyAndHand(secret)
|
||||
case "skip" => lobby.skip(secret, extractCommandArgument((request.body \ "players").validate[Set[Player.Id]]))
|
||||
case "back" => lobby.back(secret)
|
||||
case "enableRule" => lobby.enableRule(secret, extractCommandArgument((request.body \ "rule").validate[String]))
|
||||
case "disableRule" => lobby.disableRule(secret, extractCommandArgument((request.body \ "rule").validate[String]))
|
||||
case "redraw" => lobby.redraw(secret)
|
||||
case "setPassword" => lobby.setPassword(secret, (request.body \ "password").asOpt[String])
|
||||
case unknown => throw BadRequestException(Errors.InvalidCommand(unknown))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def newPlayer(gameCode: String) = Action(parse.json) { request: Request[JsValue] =>
|
||||
wrap {
|
||||
val name = (request.body \ "name").validate[String].getOrElse(throw BadRequestException(Errors.InvalidName()))
|
||||
store.performInLobby(gameCode) { lobby =>
|
||||
Json.toJson(lobby.newPlayer(name, (request.body \ "password").asOpt[String]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def getPlayer(gameCode: String, playerId: Int) = Action(parse.json) { request: Request[JsValue] =>
|
||||
wrap(store.readFromLobby(gameCode) { lobby =>
|
||||
val secret = extractSecret(request)
|
||||
Json.toJson(lobby.getHand(secret))
|
||||
})
|
||||
}
|
||||
|
||||
def getHistory(gameCode: String) = Action { request =>
|
||||
wrap(store.readFromLobby(gameCode) { lobby =>
|
||||
Json.toJson(lobby.gameHistory())
|
||||
})
|
||||
}
|
||||
|
||||
def newAi(gameCode: String) = Action(parse.json) { request: Request[JsValue] =>
|
||||
wrap(store.performInLobby(gameCode) { lobby =>
|
||||
val secret = extractSecret(request)
|
||||
lobby.newAi(secret)
|
||||
Json.toJson("")
|
||||
})
|
||||
}
|
||||
|
||||
def leave(gameCode: String, playerId: Int) = Action(parse.json) { request: Request[JsValue] =>
|
||||
wrap(store.performInLobby(gameCode) { lobby =>
|
||||
val secret = extractSecret(request)
|
||||
lobby.leave(secret)
|
||||
Json.toJson("")
|
||||
})
|
||||
}
|
||||
|
||||
private def extractSecret(request: Request[JsValue]) =
|
||||
request.body.validate[Player.Secret].getOrElse(throw ForbiddenException(Errors.BadlyFormedSecret()))
|
||||
|
||||
private def extractCommandArgument[T](value: JsResult[T]) =
|
||||
value.getOrElse(throw BadRequestException(Errors.InvalidCommand("")))
|
||||
|
||||
private def wrap(attempt: => JsValue) = {
|
||||
(Try(attempt) match {
|
||||
case Success(result) =>
|
||||
Ok(result)
|
||||
|
||||
case Failure(error) =>
|
||||
error match {
|
||||
case exception: RequestException =>
|
||||
Status(exception.statusCode)(exception.details.toJson())
|
||||
case _ =>
|
||||
throw error
|
||||
}
|
||||
}).as(JSON)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package massivedecks.controllers
|
||||
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import javax.inject.{Inject, Singleton}
|
||||
|
||||
import scala.concurrent.Future
|
||||
|
||||
import play.api.http.ContentTypes._
|
||||
import play.api.http.HttpErrorHandler
|
||||
import play.api.mvc.Results._
|
||||
import play.api.mvc._
|
||||
import play.api.Logger
|
||||
import massivedecks.Config
|
||||
|
||||
@Singleton
|
||||
class ErrorHandler @Inject()(getConfig: Config.Factory) extends HttpErrorHandler {
|
||||
|
||||
def onClientError(request: RequestHeader, statusCode: Int, rawMessage: String) = Future.successful {
|
||||
val config = getConfig(request)
|
||||
val (message, description) = statusCode match {
|
||||
case 400 => ("Bad Request", "there was a problem with the request you made")
|
||||
case 403 => ("Forbidden", ",you are not able to access the resource you requested")
|
||||
case 404 => ("Not Found", "the page you tried to access doesn't seem to exist")
|
||||
case _ => ("Client Error", "there was a problem with the request you made")
|
||||
}
|
||||
NotFound(views.html.error(
|
||||
config.url, config.version, statusCode, message, description,
|
||||
bugReportBody(config, request.path, s"$statusCode", rawMessage))).as(HTML)
|
||||
}
|
||||
|
||||
def onServerError(request: RequestHeader, exception: Throwable): Future[Result] = Future.successful {
|
||||
val config = getConfig(request)
|
||||
val description = "the server had a problem trying to handle your request"
|
||||
Logger.error(exception.getMessage)
|
||||
NotFound(views.html.error(
|
||||
config.url, config.version, 500, "Server Error", description,
|
||||
bugReportBody(config, request.path, exception.getClass.getSimpleName, exception.getMessage))).as(HTML)
|
||||
}
|
||||
|
||||
def bugReportBody(config: Config, path: String, error: String, detail: String) = {
|
||||
val extra = if (detail == null || detail.isEmpty) "" else s"\n\nDetail:\n\t${detail.take(1000)}"
|
||||
val v = config.version
|
||||
val version = if (v.isEmpty) "Not Specified" else v
|
||||
val text =
|
||||
(s"I was [a short explanation of what you were doing] when I got sent to '$path' and got a '$error' error.$extra"
|
||||
++ s"\n\nApplication Info:\n\tVersion: $version\n\tURL: ${config.url}")
|
||||
URLEncoder.encode(text, StandardCharsets.UTF_8.name())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package massivedecks.controllers
|
||||
|
||||
import javax.inject.Inject
|
||||
|
||||
import play.api.libs.json.Json
|
||||
import play.api.mvc.{Action, Controller}
|
||||
import play.api.cache.Cached
|
||||
import play.api.Play.current
|
||||
|
||||
import massivedecks.Config
|
||||
|
||||
class Pages @Inject() (cached: Cached, getConfig: Config.Factory) extends Controller {
|
||||
|
||||
def index() = Action { request =>
|
||||
val config = getConfig(request)
|
||||
if (!config.requestIsCanonical) {
|
||||
Redirect(config.canonicalUrlTo(request.path))
|
||||
} else {
|
||||
Ok(views.html.index(config.url, config.version)).as(HTML)
|
||||
}
|
||||
}
|
||||
|
||||
def manifest() = cached("manifest")(Action { request =>
|
||||
val config = getConfig(request)
|
||||
val json = Json.obj(
|
||||
"name" -> "Massive Decks",
|
||||
"description" -> "An online party game inspired by Cards Against Humanity.",
|
||||
"icons" -> Json.arr(
|
||||
Json.obj(
|
||||
"src" -> controllers.routes.Assets.versioned("icon.png").url,
|
||||
"sizes" -> "192x192",
|
||||
"type" -> "image/png",
|
||||
"density" -> 4.0
|
||||
)
|
||||
),
|
||||
"lang" -> "en",
|
||||
"start_url" -> config.path,
|
||||
"display" -> "standalone",
|
||||
"orientation" -> "portrait-primary",
|
||||
"background_color" -> "#e5e5e5",
|
||||
"theme_color" -> "#2196F3"
|
||||
)
|
||||
Ok(json.toString).as(withCharset("application/manifest+json"))
|
||||
})
|
||||
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package massivedecks.exceptions
|
||||
|
||||
import massivedecks.models.Errors.ErrorDetails
|
||||
import play.api.http.Status
|
||||
|
||||
/**
|
||||
* An exception representing a request that failed due to the request being faulty.
|
||||
* Represents a 400 error.
|
||||
*
|
||||
* @param details The details of the error to be given to the requester.
|
||||
*/
|
||||
case class BadRequestException(details: ErrorDetails) extends RequestException {
|
||||
val statusCode = Status.BAD_REQUEST
|
||||
}
|
||||
object BadRequestException {
|
||||
/**
|
||||
* Verify the given requirement - if not met, throw a BadRequestException.
|
||||
* @param requirement The requirement to verify
|
||||
* @param details The details.
|
||||
*/
|
||||
def verify(requirement: Boolean, details: ErrorDetails): Unit = {
|
||||
if (!requirement) {
|
||||
throw BadRequestException(details)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package massivedecks.exceptions
|
||||
|
||||
import massivedecks.models.Errors.ErrorDetails
|
||||
import play.api.http.Status
|
||||
|
||||
/**
|
||||
* An exception representing a request that failed due to the request not being authorized.
|
||||
* Represents a 403 error.
|
||||
*
|
||||
* @param details The details of the error to be given to the requester.
|
||||
*/
|
||||
case class ForbiddenException(details: ErrorDetails) extends RequestException {
|
||||
val statusCode = Status.FORBIDDEN
|
||||
}
|
||||
object ForbiddenException {
|
||||
/**
|
||||
* Verify the given requirement - if not met, throw a ForbiddenException.
|
||||
* @param requirement The requirement to verify
|
||||
* @param details The details.
|
||||
*/
|
||||
def verify(requirement: Boolean, details: ErrorDetails): Unit = {
|
||||
if (!requirement) {
|
||||
throw ForbiddenException(details)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package massivedecks.exceptions
|
||||
|
||||
import massivedecks.models.Errors.ErrorDetails
|
||||
import play.api.http.Status
|
||||
|
||||
/**
|
||||
* An exception representing a request that failed due to the requested resource not being found.
|
||||
* Represents a 404 error.
|
||||
*
|
||||
* @param details The details of the error to be given to the requester.
|
||||
*/
|
||||
case class NotFoundException(details: ErrorDetails) extends RequestException {
|
||||
val statusCode = Status.NOT_FOUND
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package massivedecks.exceptions
|
||||
|
||||
import massivedecks.models.Errors.ErrorDetails
|
||||
|
||||
trait RequestException extends Exception {
|
||||
def statusCode: Int
|
||||
def details: ErrorDetails
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package massivedecks.exceptions
|
||||
|
||||
import massivedecks.models.Errors.ErrorDetails
|
||||
import play.api.http.Status
|
||||
|
||||
/**
|
||||
* An exception representing a request that failed due to a request being made by the server failing.
|
||||
* Represents a 502 error.
|
||||
*
|
||||
* @param details The details of the error to be given to the requester.
|
||||
*/
|
||||
case class RequestFailedException(details: ErrorDetails) extends RequestException {
|
||||
val statusCode = Status.BAD_GATEWAY
|
||||
}
|
||||
object RequestFailedException {
|
||||
/**
|
||||
* Verify the given requirement - if not met, throw a RequestFailedException.
|
||||
* @param requirement The requirement to verify
|
||||
* @param details The details.
|
||||
*/
|
||||
def verify(requirement: Boolean, details: ErrorDetails): Unit = {
|
||||
if (!requirement) {
|
||||
throw RequestFailedException(details)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package massivedecks.lobby
|
||||
|
||||
import massivedecks.models.Game.{Config => ConfigModel}
|
||||
import massivedecks.models.cardcast.CardcastDeck
|
||||
import massivedecks.notifications.Notifiers
|
||||
|
||||
class Config(notifiers: Notifiers) {
|
||||
|
||||
var decks: List[CardcastDeck] = List()
|
||||
var houseRules: Set[String] = Set()
|
||||
var password: Option[String] = None
|
||||
|
||||
def config = ConfigModel(decks.map(deck => deck.info), houseRules, password)
|
||||
|
||||
def addDeck(deck: CardcastDeck): Unit = {
|
||||
decks = decks :+ deck
|
||||
notifiers.configChange(config)
|
||||
}
|
||||
|
||||
def addHouseRule(rule: String): Unit = {
|
||||
houseRules = houseRules + rule
|
||||
notifiers.configChange(config)
|
||||
}
|
||||
|
||||
def removeHouseRule(rule: String): Unit = {
|
||||
houseRules = houseRules - rule
|
||||
notifiers.configChange(config)
|
||||
}
|
||||
|
||||
def setPassword(newPass: Option[String]) = {
|
||||
password = newPass.flatMap(pass => if (pass.isEmpty) None else newPass)
|
||||
notifiers.configChange(config)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package massivedecks.lobby
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
import scala.util.Random
|
||||
|
||||
import massivedecks.exceptions.BadRequestException
|
||||
import massivedecks.models.Errors
|
||||
import massivedecks.models.Game.{Call, Response}
|
||||
import massivedecks.models.cardcast.CardcastDeck
|
||||
|
||||
/**
|
||||
* A live deck of cards in a game, constructed from a collection of cardcast decks.
|
||||
*
|
||||
* @param decks The cardcast decks.
|
||||
* @throws BadRequestException with key "invalid-deck-configuration" if there are no calls or responses. Has value
|
||||
* "reason" explaining the issue.
|
||||
*/
|
||||
case class Deck(decks: List[CardcastDeck]) {
|
||||
/**
|
||||
* The current calls in the deck.
|
||||
*/
|
||||
var calls: List[Call] = List()
|
||||
|
||||
/**
|
||||
* The current responses in the deck.
|
||||
*/
|
||||
var responses: List[Response] = List()
|
||||
|
||||
resetCalls()
|
||||
resetResponses()
|
||||
BadRequestException.verify(calls.nonEmpty, Errors.InvalidDeckConfiguration("The decks for the game have no calls."))
|
||||
BadRequestException.verify(responses.nonEmpty, Errors.InvalidDeckConfiguration("The decks for the game have no responses."))
|
||||
|
||||
/**
|
||||
* Draw a call from the deck, reshuffling if the deck runs out.
|
||||
*
|
||||
* @return The drawn call.
|
||||
*/
|
||||
def drawCall(): Call = {
|
||||
if (calls.isEmpty) {
|
||||
resetCalls()
|
||||
}
|
||||
val drawn = calls.head
|
||||
calls = calls.tail
|
||||
drawn
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the given number of responses, reshuffling if the deck runs out.
|
||||
*
|
||||
* @param count The number of responses to draw.
|
||||
* @return The drawn responses.
|
||||
*/
|
||||
def drawResponses(count: Int): List[Response] = {
|
||||
if (responses.length < count) {
|
||||
val partial = responses.take(count)
|
||||
resetResponses()
|
||||
partial ++ drawResponses(count - partial.length)
|
||||
} else {
|
||||
val drawn = responses.take(count)
|
||||
responses = responses.drop(count)
|
||||
drawn
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the active deck with all calls, shuffling them into a random order.
|
||||
*/
|
||||
def resetCalls(): Unit = {
|
||||
calls = decks.flatMap(deck => deck.calls).map(call => call.copy(id = UUID.randomUUID().toString))
|
||||
shuffleCalls()
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the active deck with all responses, shuffling them into a random order.
|
||||
*/
|
||||
def resetResponses(): Unit = {
|
||||
responses = decks.flatMap(deck => deck.responses).map(response => response.copy(id = UUID.randomUUID().toString))
|
||||
shuffleResponses()
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle the active deck of calls and responses.
|
||||
*/
|
||||
def shuffle(): Unit = {
|
||||
shuffleCalls()
|
||||
shuffleResponses()
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle the active deck of calls.
|
||||
*/
|
||||
def shuffleCalls(): Unit = calls = Random.shuffle(calls)
|
||||
|
||||
/**
|
||||
* Shuffle the active deck of responses.
|
||||
*/
|
||||
def shuffleResponses(): Unit = responses = Random.shuffle(responses)
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
package massivedecks.lobby
|
||||
|
||||
import scala.concurrent.duration._
|
||||
import scala.concurrent.{ExecutionContext, Future}
|
||||
import scala.util.Random
|
||||
|
||||
import massivedecks.models.Game._
|
||||
import massivedecks.exceptions.BadRequestException
|
||||
import massivedecks.models.{Errors, Player}
|
||||
import massivedecks.notifications.Notifiers
|
||||
import massivedecks.Util
|
||||
|
||||
/**
|
||||
* The state of the game.
|
||||
*
|
||||
* @param players The players in the lobby the game is in.
|
||||
* @param config The configuration for the lobby the game is in.
|
||||
* @param notifiers The notifiers for the lobby the game is in.
|
||||
*/
|
||||
class Game(players: Players, config: Config, notifiers: Notifiers) (implicit context: ExecutionContext) {
|
||||
|
||||
import Game._
|
||||
|
||||
/**
|
||||
* The history of the game.
|
||||
*/
|
||||
var history: List[Round.Finished] = List()
|
||||
|
||||
private val deck: Deck = Deck(config.decks)
|
||||
private var czarIndex = 0
|
||||
private var hands: Map[Player.Id, Hand] = players.ids.map(id => {
|
||||
val hand = new Hand(deck.drawResponses(Hand.size))
|
||||
notifiers.handChange(id, hand)
|
||||
id -> hand
|
||||
}).toMap
|
||||
private var roundProgress: RoundProgress = Playing
|
||||
private var roundTimedOut: Boolean = false
|
||||
|
||||
// Initial values overwritten by beginRound() call below, just keeping the compiler happy.
|
||||
private var czar: Player.Id = Player.Id(0)
|
||||
private var call: Call = deck.drawCall()
|
||||
private var playedCards: Map[Player.Id, Option[List[Response]]] = Map()
|
||||
beginRound(true)
|
||||
|
||||
/**
|
||||
* @return The ids of the players that are taking part in the current round.
|
||||
*/
|
||||
def playersInRound = playedCards.keySet
|
||||
|
||||
/**
|
||||
* @return The model representing the current round.
|
||||
*/
|
||||
def round: Round = roundProgress match {
|
||||
case Playing =>
|
||||
Round(czar, call, Round.State.Playing(roundTimedOut, numberOfPlayersWhoHavePlayed))
|
||||
case Judging(shuffled, _) =>
|
||||
Round(czar, call, Round.State.Judging(roundTimedOut, shuffled))
|
||||
case Finished(shuffled, playedOrder, winner) =>
|
||||
Round(czar, call, Round.State.Finished(shuffled, PlayedByAndWinner(playedOrder, winner)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a player to the game, giving them a hand.
|
||||
*
|
||||
* @param playerId The id of the player to add.
|
||||
*/
|
||||
def addPlayer(playerId: Player.Id): Unit = {
|
||||
hands += (playerId -> Hand(deck.drawResponses(Hand.size)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a player from the game, removing their hand and any played cards for the round.
|
||||
* Note this may invalidate the round (if the player is the czar) or finish the round.
|
||||
*
|
||||
* @param playerId The id of the player to remove.
|
||||
*/
|
||||
def playerLeft(playerId: Player.Id): Unit = {
|
||||
hands = hands.filterKeys(id => id != playerId)
|
||||
playedCards = playedCards.filterKeys(id => id != playerId)
|
||||
if (playerId == round.czar) {
|
||||
invalidateRound()
|
||||
} else {
|
||||
checkIfFinishedPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin the round.
|
||||
*
|
||||
* @param firstRound If this is the first round in the game.
|
||||
* @throws BadRequestException with key "not-enough-players" if there are not enough players in the game to begin a
|
||||
* round. The value "required" gives the minimum number of players needed for the request
|
||||
* to succeed.
|
||||
*/
|
||||
def beginRound(firstRound: Boolean = false) = {
|
||||
if (players.amount < Players.minimum) {
|
||||
throw BadRequestException(Errors.NotEnoughPlayers(Players.minimum))
|
||||
}
|
||||
|
||||
czar = nextCzar()
|
||||
call = deck.drawCall()
|
||||
|
||||
if (firstRound) {
|
||||
notifiers.gameStart(czar, call)
|
||||
} else {
|
||||
notifiers.roundStart(czar, call)
|
||||
}
|
||||
|
||||
players.updatePlayers(players.setPlayerStatus(Player.NotPlayed))
|
||||
players.updatePlayer(czar, players.setPlayerStatus(Player.Czar))
|
||||
|
||||
playedCards = (for (player <- players.players if !Player.Status.notInRound.contains(player.status) && !player.left) yield player.id -> None).toMap
|
||||
roundProgress = Playing
|
||||
|
||||
val slots = call.slots
|
||||
if (slots > Hand.extraDrawAfter) {
|
||||
val toDraw = slots - Hand.extraDrawAfter + 1
|
||||
for (player <- playersInRound) {
|
||||
val hand = hands(player).hand
|
||||
val newHand = Hand(hand ++ deck.drawResponses(toDraw))
|
||||
hands += (player -> newHand)
|
||||
notifiers.handChange(player, newHand)
|
||||
}
|
||||
}
|
||||
|
||||
for (ai <- players.ais) {
|
||||
play(ai.id, getIdsForFirstXCardsInHand(ai.id, call.slots))
|
||||
}
|
||||
|
||||
startRoundTimer(round.state.getClass, call)
|
||||
checkIfFinishedPlaying()
|
||||
}
|
||||
|
||||
private def getIdsForFirstXCardsInHand(playerId: Player.Id, amount: Int): List[String] = {
|
||||
val hand = hands(playerId).hand
|
||||
(0 until amount).map(i => hand(i).id).toList
|
||||
}
|
||||
|
||||
private def startRoundTimer(state: Class[_ <: Round.State], call: Call) = Future {
|
||||
roundTimedOut = false
|
||||
Util.wait(Game.roundTimeLimit)
|
||||
if (state.isInstance(round.state) && round.call == call) {
|
||||
roundTimedOut = true
|
||||
notifiers.roundTimeLimitHit()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play the given cards into the round.
|
||||
*
|
||||
* @param playerId The player the play is for.
|
||||
* @param cardIds The ids of the responses to play.
|
||||
* @throws BadRequestException with key "not-in-round" if the player is not in the round.
|
||||
* @throws BadRequestException with key "already-played" if the player has already played into the round.
|
||||
* @throws BadRequestException with key "already-judging" if the round is already in it's judging state.
|
||||
* @throws BadRequestException with key "wrong-number-of-cards-played" if the wrong number of responses were played.
|
||||
* The value "got" is the number of cards played, the value "expected" is the number
|
||||
* required for the request to succeed
|
||||
* @throws BadRequestException with key "invalid-card-id-given" if any of the card ids are not in the given player's
|
||||
* hand.
|
||||
*/
|
||||
def play(playerId: Player.Id, cardIds: List[String]): Unit = {
|
||||
BadRequestException.verify(playersInRound.contains(playerId), Errors.NotInRound())
|
||||
BadRequestException.verify(playedCards(playerId).isEmpty, Errors.AlreadyPlayed())
|
||||
BadRequestException.verify(roundProgress == Playing, Errors.AlreadyJudging())
|
||||
BadRequestException.verify(cardIds.length == call.slots, Errors.WrongNumberOfCardsPlayed(cardIds.length, call.slots))
|
||||
val hand = hands(playerId).hand
|
||||
val toPlay: List[Response] = cardIds.flatMap(id => hand.find(response => response.id == id))
|
||||
BadRequestException.verify(toPlay.length == cardIds.length, Errors.InvalidCardId())
|
||||
val toDraw = Hand.size - (hand.length - toPlay.length)
|
||||
val newHand = Hand(hand.filter(response => !toPlay.contains(response)) ++ deck.drawResponses(toDraw))
|
||||
hands += (playerId -> newHand)
|
||||
playedCards += (playerId -> Some(toPlay))
|
||||
players.updatePlayer(playerId, players.setPlayerStatus(Player.Played))
|
||||
notifiers.roundPlayed(numberOfPlayersWhoHavePlayed)
|
||||
checkIfFinishedPlaying()
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the winning play for the round.
|
||||
*
|
||||
* @param playerId The player who is choosing the winner.
|
||||
* @param winner The index of the played responses being chosen.
|
||||
* @throws BadRequestException with key "not-czar" if the current player is not the czar.
|
||||
* @throws BadRequestException with key "not-judging" if the round is not yet in the judging phase.
|
||||
* @throws BadRequestException with key "no-such-played-cards" if the index does not exist.
|
||||
* @throws BadRequestException with key "already-judged" if the round is already finished.
|
||||
*/
|
||||
def choose(playerId: Player.Id, winner: Int): Unit = {
|
||||
BadRequestException.verify(playerId == czar, Errors.NotCzar())
|
||||
roundProgress match {
|
||||
case Playing =>
|
||||
throw BadRequestException(Errors.NotJudging())
|
||||
case Judging(responses, playedOrder) =>
|
||||
playedOrder.lift(winner) match {
|
||||
case Some(winnerId) =>
|
||||
players.updatePlayer(winnerId, players.modifyPlayerScore(1))
|
||||
roundProgress = Finished(responses, playedOrder, winnerId)
|
||||
val finishedRound = Round.Finished(round.czar, round.call, Round.State.Finished(responses, PlayedByAndWinner(playedOrder, winnerId)))
|
||||
history = finishedRound :: history
|
||||
notifiers.roundEnd(finishedRound)
|
||||
|
||||
case None =>
|
||||
throw BadRequestException(Errors.NoSuchPlayedCards())
|
||||
}
|
||||
case Finished(_, _, _) =>
|
||||
throw BadRequestException(Errors.AlreadyJudged())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the hand of the given player.
|
||||
*
|
||||
* @param playerId The id of the player.
|
||||
* @return The hand for the player.
|
||||
*/
|
||||
def getHand(playerId: Player.Id): Hand = hands(playerId)
|
||||
|
||||
/**
|
||||
* Redraw the hand of the given player.
|
||||
*
|
||||
* @param playerId The id of the player.
|
||||
* @throws BadRequestException with the key "not-enough-points" if the player doesn't have enough points.
|
||||
*/
|
||||
def redraw(playerId: Player.Id): Unit = {
|
||||
val player = players.getPlayer(playerId)
|
||||
BadRequestException.verify(player.score > 0, Errors.NotEnoughPoints())
|
||||
players.updatePlayer(playerId, players.modifyPlayerScore(-1))
|
||||
hands += (playerId -> Hand(deck.drawResponses(Hand.size)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Start skipping the given players.
|
||||
*
|
||||
* @param playerId The player requesting the skipping.
|
||||
* @param playerIds The players to start skipping.
|
||||
*/
|
||||
def skip(playerId: Player.Id, playerIds: Set[Player.Id]): Unit = {
|
||||
for (id <- playerIds) {
|
||||
players.updatePlayer(id, players.setPlayerStatus(Player.Skipping))
|
||||
playedCards = playedCards.filterKeys(pId => pId != id)
|
||||
}
|
||||
if (playerIds.contains(round.czar)) {
|
||||
invalidateRound()
|
||||
} else {
|
||||
checkIfFinishedPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
private def invalidateRound(): Unit = {
|
||||
beginRound()
|
||||
}
|
||||
|
||||
private def checkIfFinishedPlaying(): Unit = {
|
||||
if (roundProgress == Playing && playedCards.values.forall(item => item.isDefined)) {
|
||||
roundTimedOut = false
|
||||
startRoundTimer(round.state.getClass, round.call)
|
||||
val shuffled = Random.shuffle(playedCards)
|
||||
val shuffledPlayedCards = shuffled.map(item => item._2.get).toList
|
||||
roundProgress = Judging(shuffledPlayedCards, shuffled.map(item => item._1).toList)
|
||||
notifiers.roundJudging(shuffledPlayedCards)
|
||||
}
|
||||
}
|
||||
|
||||
private def numberOfPlayersWhoHavePlayed = {
|
||||
playedCards.values.count(played => played.isDefined)
|
||||
}
|
||||
|
||||
private def nextCzar(): Player.Id = {
|
||||
if (czarIndex >= players.amount) {
|
||||
czarIndex = 0
|
||||
}
|
||||
val player = players.players(czarIndex)
|
||||
czarIndex += 1
|
||||
if (players.canBeCzar(player)) {
|
||||
nextCzar()
|
||||
} else {
|
||||
player.id
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
object Game {
|
||||
val roundTimeLimit = 120.seconds
|
||||
|
||||
/**
|
||||
* Represents the progress through a round.
|
||||
*/
|
||||
sealed trait RoundProgress
|
||||
|
||||
/**
|
||||
* Indicates the game is being played.
|
||||
*/
|
||||
case object Playing extends RoundProgress
|
||||
|
||||
/**
|
||||
* Indicates the round is being judged.
|
||||
*
|
||||
* @param shuffled The shuffled list of played responses.
|
||||
* @param playedOrder A list of player ids for the players who played the responses matching the order shuffled.
|
||||
*/
|
||||
case class Judging(shuffled: List[List[Response]], playedOrder: List[Player.Id]) extends RoundProgress
|
||||
|
||||
/**
|
||||
* Indicates the round is done.
|
||||
*
|
||||
* @param shuffled The shuffled list of played responses.
|
||||
* @param playedOrder A list of player ids for the players who played the responses matching the order shuffled.
|
||||
* @param winner The id of the winner of the round.
|
||||
*/
|
||||
case class Finished(shuffled: List[List[Response]], playedOrder: List[Player.Id], winner: Player.Id) extends RoundProgress
|
||||
}
|
||||
@@ -1,416 +0,0 @@
|
||||
package massivedecks.lobby
|
||||
|
||||
import javax.inject.Inject
|
||||
|
||||
import scala.concurrent.{Await, ExecutionContext, Future}
|
||||
import scala.concurrent.duration._
|
||||
import scala.util.{Failure, Success, Try}
|
||||
|
||||
import massivedecks.exceptions.{BadRequestException, ForbiddenException, RequestFailedException}
|
||||
import massivedecks.notifications.Notifiers
|
||||
import massivedecks.models.{Errors, Player, Game => GameModel, Lobby => LobbyModel}
|
||||
import massivedecks.models.Game.Formatters._
|
||||
import massivedecks.models.Lobby.Formatters._
|
||||
import play.api.libs.iteratee.{Enumerator, Iteratee}
|
||||
import play.api.libs.json.{JsValue, JsObject, Json}
|
||||
import massivedecks.Util
|
||||
import massivedecks.cardcast.CardcastAPI
|
||||
|
||||
object Lobby {
|
||||
|
||||
/**
|
||||
* Factory for dependency injection.
|
||||
*/
|
||||
class Factory @Inject() (cardcast: CardcastAPI) (implicit context: ExecutionContext) {
|
||||
def build(gameCode: String, ownerName: String) = new Lobby(cardcast, gameCode, ownerName)
|
||||
}
|
||||
|
||||
/**
|
||||
* How long to wait after a player disconnects to allow for them to reconnect without treating them as disconnected.
|
||||
*/
|
||||
val disconnectGracePeriod: FiniteDuration = 5.seconds
|
||||
/**
|
||||
* How long to wait for calls to Cardcast to complete.
|
||||
*/
|
||||
val cardCastWaitPeriod: FiniteDuration = 10.seconds
|
||||
}
|
||||
/**
|
||||
* Represents a game lobby.
|
||||
*
|
||||
* @param cardcast The cardcast api.
|
||||
* @param gameCode The game code for the lobby.
|
||||
*/
|
||||
class Lobby(cardcast: CardcastAPI, val gameCode: String, ownerName: String)(implicit context: ExecutionContext) {
|
||||
|
||||
/**
|
||||
* The game in progress if there is one.
|
||||
*/
|
||||
var game: Option[Game] = None
|
||||
|
||||
/**
|
||||
* Notifiers for the lobby.
|
||||
*/
|
||||
val notifiers: Notifiers = new Notifiers()
|
||||
|
||||
/**
|
||||
* Configuration for the lobby.
|
||||
*/
|
||||
var config = new Config(notifiers)
|
||||
|
||||
/**
|
||||
* The players in the lobby.
|
||||
*/
|
||||
val players: Players = new Players(notifiers)
|
||||
|
||||
def gameState = game match {
|
||||
case None => GameModel.State.Configuring()
|
||||
case Some(g) => GameModel.State.Playing(g.round)
|
||||
}
|
||||
|
||||
val owner = newPlayer(ownerName, None)
|
||||
|
||||
/**
|
||||
* @return The model for the lobby.
|
||||
*/
|
||||
def lobby = LobbyModel.Lobby(gameCode, owner.id, config.config, players.players, gameState)
|
||||
|
||||
/**
|
||||
* Add a new player to the lobby.
|
||||
*
|
||||
* @param name The name for the player.
|
||||
* @return The secret for the player.
|
||||
* @throws BadRequestException with key "name-in-use" if there is a player in the lobby with the same name.
|
||||
* @throws ForbiddenException with key "password-wrong" if the password is required and wrong.
|
||||
*/
|
||||
def newPlayer(name: String, password: Option[String]): Player.Secret = {
|
||||
ForbiddenException.verify(config.password.isEmpty || config.password == password, Errors.PasswordWrong())
|
||||
val secret = players.addPlayer(name)
|
||||
if (game.isDefined) {
|
||||
game.get.addPlayer(secret.id)
|
||||
}
|
||||
setPlayerDisconnectedAfterGracePeriod(secret.id)
|
||||
secret
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to add the deck to the lobby.
|
||||
*
|
||||
* @param secret The secret for the player making the request.
|
||||
* @param playCode The cardcast play code for the deck.
|
||||
* @return An empty response.
|
||||
* @throws ForbiddenException with key "secret-wrong-or-not-a-player" if the secret is invalid.
|
||||
* @throws ForbiddenException with key "not-owner" if the requester is not the owner.
|
||||
* @throws RequestFailedException with the key "cardcast-timeout" if the request to cardcast doesn't complete.
|
||||
*/
|
||||
def addDeck(secret: Player.Secret, playCode: String): JsValue = {
|
||||
players.validateSecret(secret)
|
||||
validateIsOwner(secret)
|
||||
Try(Await.ready({
|
||||
cardcast.deck(playCode).map { deck =>
|
||||
config.addDeck(deck)
|
||||
}
|
||||
}, Lobby.cardCastWaitPeriod)) match {
|
||||
case Success(result) =>
|
||||
result.value.get.get
|
||||
case Failure(exception) =>
|
||||
throw RequestFailedException(Errors.CardcastTimeout())
|
||||
}
|
||||
EmptyResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new ai to the lobby.
|
||||
*
|
||||
* @param secret The secret of the player making the request.
|
||||
* @throws ForbiddenException with key "secret-wrong-or-not-a-player" if the secret is invalid.
|
||||
* @throws ForbiddenException with key "not-owner" if the requester is not the owner.
|
||||
*/
|
||||
def newAi(secret: Player.Secret): Unit = {
|
||||
players.validateSecret(secret)
|
||||
validateIsOwner(secret)
|
||||
players.addAi()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new game in the lobby.
|
||||
*
|
||||
* @param secret The secret of the player making the request.
|
||||
* @return The hand of the player making the request in the new game.
|
||||
* @throws ForbiddenException with key "secret-wrong-or-not-a-player" if the secret is invalid.
|
||||
* @throws BadRequestException with the key "game-in-progress" if the game has already started.
|
||||
*/
|
||||
def newGame(secret: Player.Secret): JsValue = {
|
||||
players.validateSecret(secret)
|
||||
if (game.isDefined) {
|
||||
throw BadRequestException(Errors.GameInProgress())
|
||||
}
|
||||
val current = new Game(players, config, notifiers)
|
||||
game = Some(current)
|
||||
Json.toJson(getHand(secret))
|
||||
}
|
||||
|
||||
/**
|
||||
* Play the given cards into the round.
|
||||
*
|
||||
* @param secret The secret of the player making the request.
|
||||
* @param cardIds The ids of the responses to play.
|
||||
* @return The hand of the player making the request after the cards have been played and replacements drawn.
|
||||
* @throws ForbiddenException with key "secret-wrong-or-not-a-player" if the secret is invalid.
|
||||
* @throws BadRequestException with key "no-game-in-progress" if there is not a game underway.
|
||||
* @throws BadRequestException with key "not-in-round" if the player is not in the round.
|
||||
* @throws BadRequestException with key "already-played" if the player has already played into the round.
|
||||
* @throws BadRequestException with key "already-judging" if the round is already in it's judging state.
|
||||
* @throws BadRequestException with key "wrong-number-of-cards-played" if the wrong number of responses were played.
|
||||
* The value "got" is the number of cards played, the value "expected" is the number
|
||||
* required for the request to succeed
|
||||
* @throws BadRequestException with key "invalid-card-id-given" if any of the card ids are not in the given player's
|
||||
* hand.
|
||||
*/
|
||||
def play(secret: Player.Secret, cardIds: List[String]): JsValue = {
|
||||
players.validateSecret(secret)
|
||||
validateInGame().play(secret.id, cardIds)
|
||||
Json.toJson(getHand(secret))
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the winning play for the round.
|
||||
*
|
||||
* @param secret The secret of the player making the request.
|
||||
* @param winner The index of the played responses being chosen.
|
||||
* @return An empty response.
|
||||
* @throws ForbiddenException with key "secret-wrong-or-not-a-player" if the secret is invalid.
|
||||
* @throws BadRequestException with key "no-game-in-progress" if there is not a game underway.
|
||||
* @throws BadRequestException with key "not-czar" if the current player is not the czar.
|
||||
* @throws BadRequestException with key "not-judging" if the round is not yet in the judging phase.
|
||||
* @throws BadRequestException with key "no-such-played-cards" if the index does not exist.
|
||||
* @throws BadRequestException with key "already-judged" if the round is already finished.
|
||||
*/
|
||||
def choose(secret: Player.Secret, winner: Int): JsValue = {
|
||||
players.validateSecret(secret)
|
||||
val game = validateInGame()
|
||||
game.choose(secret.id, winner)
|
||||
beginRound()
|
||||
EmptyResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the hand of the player.
|
||||
*
|
||||
* @param secret The secret of the player making the request.
|
||||
* @return The hand of the player.
|
||||
* @throws ForbiddenException with key "secret-wrong-or-not-a-player" if the secret is invalid.
|
||||
* @throws BadRequestException with key "no-game-in-progress" if there is not a game underway.
|
||||
*/
|
||||
def getHand(secret: Player.Secret): GameModel.Hand = {
|
||||
players.validateSecret(secret)
|
||||
validateInGame().getHand(secret.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return The history of the current game.
|
||||
* @throws BadRequestException with key "no-game-in-progress" if there is not a game underway.
|
||||
*/
|
||||
def gameHistory() : List[GameModel.Round.Finished] = {
|
||||
validateInGame().history
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the lobby and hand models for the lobby.
|
||||
*
|
||||
* @param secret The secret of the player making the request.
|
||||
* @return The lobby and the hand of the player.
|
||||
* @throws ForbiddenException with key "secret-wrong-or-not-a-player" if the secret is invalid.
|
||||
*/
|
||||
def getLobbyAndHand(secret: Player.Secret): JsValue = {
|
||||
players.validateSecret(secret)
|
||||
Json.toJson(lobbyAndHand(secret))
|
||||
}
|
||||
|
||||
private def lobbyAndHand(secret: Player.Secret): LobbyModel.LobbyAndHand = {
|
||||
val hand = game match {
|
||||
case Some(_) =>
|
||||
getHand(secret)
|
||||
case None =>
|
||||
GameModel.Hand(List())
|
||||
}
|
||||
LobbyModel.LobbyAndHand(lobby, hand)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the given player as having left the game.
|
||||
*
|
||||
* @param secret The secret of the player making the request.
|
||||
* @throws ForbiddenException with key "secret-wrong-or-not-a-player" if the secret is invalid.
|
||||
*/
|
||||
def leave(secret: Player.Secret): Unit = {
|
||||
players.validateSecret(secret)
|
||||
players.leave(secret.id)
|
||||
game.foreach { current =>
|
||||
if (players.activePlayers.length < Players.minimum) {
|
||||
endGame()
|
||||
}
|
||||
current.playerLeft(secret.id)
|
||||
}
|
||||
}
|
||||
|
||||
private def endGame(): Unit = {
|
||||
game = None
|
||||
players.updatePlayers(players.setPlayerStatus(Player.Neutral))
|
||||
notifiers.gameEnd()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start skipping the given players.
|
||||
*
|
||||
* @param secret The secret of the player making the request.
|
||||
* @param playerIds The players to start skipping.
|
||||
* @throws ForbiddenException with key "secret-wrong-or-not-a-player" if the secret is invalid.
|
||||
* @throws BadRequestException with key "no-game-in-progress" if there is not a game underway.
|
||||
* @throws BadRequestException with key "not-enough-players" if the game would end by skipping the given
|
||||
* players.
|
||||
* @throws BadRequestException with key "players-must-be-skippable" if the players are not skippable.
|
||||
* @return An empty response.
|
||||
*/
|
||||
def skip(secret: Player.Secret, playerIds: Set[Player.Id]): JsValue = {
|
||||
players.validateSecret(secret)
|
||||
val game = validateInGame()
|
||||
BadRequestException.verify((players.activePlayers.length - playerIds.size) >= Players.minimum, Errors.NotEnoughPlayers(Players.minimum))
|
||||
val requestedPlayers = players.players.filter(player => playerIds.contains(player.id))
|
||||
if (!game.round.state.afterTimeLimit) {
|
||||
BadRequestException.verify(requestedPlayers.forall(player => player.disconnected), Errors.PlayersMustBeSkippable())
|
||||
} else {
|
||||
if (game.round.state.isPlaying) {
|
||||
BadRequestException.verify(requestedPlayers.forall(player => player.status == Player.Czar), Errors.PlayersMustBeSkippable())
|
||||
} else {
|
||||
BadRequestException.verify(requestedPlayers.forall(player => player.status == Player.NotPlayed), Errors.PlayersMustBeSkippable())
|
||||
}
|
||||
}
|
||||
game.skip(secret.id, playerIds)
|
||||
EmptyResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the given player as back into the game.
|
||||
*
|
||||
* @param secret The secret of the player making the request.
|
||||
* @throws ForbiddenException with key "secret-wrong-or-not-a-player" if the secret is invalid.
|
||||
* @throws BadRequestException with key "not-being-skipped" if the player was not being skipped.
|
||||
* @return An empty response.
|
||||
*/
|
||||
def back(secret: Player.Secret): JsValue = {
|
||||
players.validateSecret(secret)
|
||||
players.back(secret.id)
|
||||
EmptyResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Redraw the hand of the given player.
|
||||
*
|
||||
* @param secret The secret of the player making the request.
|
||||
* @throws ForbiddenException with key "secret-wrong-or-not-a-player" if the secret is invalid.
|
||||
* @throws BadRequestException with key "rule-not-enabled" if the rule is not enabled.
|
||||
* @throws BadRequestException with key "no-game-in-progress" if there is not a game underway.
|
||||
* @throws BadRequestException with the key "not-enough-points-to-redraw" if the player doesn't have enough points.
|
||||
* @return The hand of the player.
|
||||
*/
|
||||
def redraw(secret: Player.Secret): JsValue = {
|
||||
players.validateSecret(secret)
|
||||
BadRequestException.verify(config.houseRules.contains("reboot"), Errors.RuleNotEnabled())
|
||||
validateInGame().redraw(secret.id)
|
||||
Json.toJson(getHand(secret))
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the given rule.
|
||||
*
|
||||
* @param secret The secret of the player making the request.
|
||||
* @param rule The rule to enable.
|
||||
* @return An empty response.
|
||||
* @throws ForbiddenException with key "secret-wrong-or-not-a-player" if the secret is invalid.
|
||||
* @throws ForbiddenException with key "not-owner" if the requester is not the owner.
|
||||
*/
|
||||
def enableRule(secret: Player.Secret, rule: String): JsValue = {
|
||||
players.validateSecret(secret)
|
||||
validateIsOwner(secret)
|
||||
config.addHouseRule(rule)
|
||||
EmptyResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the password
|
||||
*
|
||||
* @param secret The secret of the player making the request.
|
||||
* @param password The password to set, empty means no password.
|
||||
* @return An empty response.
|
||||
* @throws ForbiddenException with key "secret-wrong-or-not-a-player" if the secret is invalid.
|
||||
* @throws ForbiddenException with key "not-owner" if the requester is not the owner.
|
||||
*/
|
||||
def setPassword(secret: Player.Secret, password: Option[String]): JsValue = {
|
||||
players.validateSecret(secret)
|
||||
validateIsOwner(secret)
|
||||
config.setPassword(password)
|
||||
EmptyResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable the given rule.
|
||||
*
|
||||
* @param secret The secret of the player making the request.
|
||||
* @param rule The rule to disable.
|
||||
* @return An empty response.
|
||||
* @throws ForbiddenException with key "secret-wrong-or-not-a-player" if the secret is invalid.
|
||||
* @throws ForbiddenException with key "not-owner" if the requester is not the owner.
|
||||
*/
|
||||
def disableRule(secret: Player.Secret, rule: String): JsValue = {
|
||||
players.validateSecret(secret)
|
||||
validateIsOwner(secret)
|
||||
config.removeHouseRule(rule)
|
||||
EmptyResponse
|
||||
}
|
||||
|
||||
def EmptyResponse: JsValue = JsObject(Seq())
|
||||
|
||||
/**
|
||||
* Register a websocket connection.
|
||||
*
|
||||
* @return The iteratee and enumerator for the websocket.
|
||||
*/
|
||||
def register(): (Iteratee[String, Unit], Enumerator[String]) = {
|
||||
notifiers.openedSocket(register, unregister)
|
||||
}
|
||||
|
||||
private def register(secret: Player.Secret): LobbyModel.LobbyAndHand = {
|
||||
players.validateSecret(secret)
|
||||
players.register(secret.id)
|
||||
lobbyAndHand(secret)
|
||||
}
|
||||
|
||||
private def unregister(playerId: Player.Id): Unit = {
|
||||
players.unregister(playerId)
|
||||
setPlayerDisconnectedAfterGracePeriod(playerId)
|
||||
}
|
||||
|
||||
private def setPlayerDisconnectedAfterGracePeriod(playerId: Player.Id): Unit = {
|
||||
Future {
|
||||
Util.wait(Lobby.disconnectGracePeriod)
|
||||
if (!players.connected.contains(playerId)) {
|
||||
players.updatePlayer(playerId, players.setPlayerDisconnected(true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private def beginRound(): Unit = {
|
||||
val game = validateInGame()
|
||||
game.beginRound()
|
||||
}
|
||||
|
||||
private def validateInGame(): Game = game match {
|
||||
case Some(state) => state
|
||||
case None => throw BadRequestException(Errors.NoGameInProgress())
|
||||
}
|
||||
|
||||
private def validateIsOwner(secret: Player.Secret) = {
|
||||
ForbiddenException.verify(owner == secret, Errors.NotOwner())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
package massivedecks.lobby
|
||||
|
||||
import scala.util.Random
|
||||
|
||||
import massivedecks.exceptions.{BadRequestException, ForbiddenException}
|
||||
import massivedecks.models.{Errors, Player}
|
||||
import massivedecks.notifications.Notifiers
|
||||
|
||||
/**
|
||||
* Players in the game.
|
||||
*/
|
||||
class Players(notifiers: Notifiers) {
|
||||
|
||||
/**
|
||||
* All players that have been in the game.
|
||||
*/
|
||||
var players: List[Player] = List()
|
||||
|
||||
/**
|
||||
* Secrets for all the ais in the game.
|
||||
*/
|
||||
var ais: Set[Player.Secret] = Set()
|
||||
|
||||
/**
|
||||
* Any players registered as connected to the game.
|
||||
*/
|
||||
var connected: Set[Player.Id] = Set()
|
||||
|
||||
private var secrets: Map[Player.Id, Player.Secret] = Map()
|
||||
private var nextId: Int = 0
|
||||
private var aiNames: List[String] = Players.aiName :: Random.shuffle(Players.aiNames)
|
||||
private var nameIteration = 0
|
||||
|
||||
/**
|
||||
* Validate the given secret as valid.
|
||||
* @param secret The secret to check.
|
||||
* @throws ForbiddenException with key "secret-wrong-or-not-a-player" if the secret is invalid.
|
||||
*/
|
||||
def validateSecret(secret: Player.Secret): Unit = {
|
||||
ForbiddenException.verify(secrets.values.exists(s => s == secret), Errors.SecretWrongOrNotAPlayer())
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a player to the game, marking them as the owner if they are the first player in the game.
|
||||
* (Notifies all clients).
|
||||
* @param name The name of the new player.
|
||||
* @return The secret for the new player.
|
||||
* @throws BadRequestException with key "name-in-use" if there is a player in the lobby with the same name.
|
||||
*/
|
||||
def addPlayer(name: String): Player.Secret = {
|
||||
BadRequestException.verify(players.forall(player => player.name != name), Errors.NameInUse())
|
||||
val id = newId()
|
||||
val player = Player(id, name)
|
||||
players = players :+ player
|
||||
val secret = Player.Secret(id)
|
||||
secrets = secrets + (id -> secret)
|
||||
notifiers.playerJoin(player)
|
||||
secret
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an Ai to the game, generating a name for it. (Notifies all clients).
|
||||
*/
|
||||
def addAi(): Unit = {
|
||||
val secret = addPlayer(generateAiName())
|
||||
updatePlayer(secret.id, setPlayerStatus(Player.Ai, Set()))
|
||||
connected += secret.id
|
||||
ais += secret
|
||||
}
|
||||
|
||||
private def generateAiName(): String = {
|
||||
val name :: rest = aiNames
|
||||
aiNames = rest
|
||||
if (rest.isEmpty) {
|
||||
nameIteration += 1
|
||||
aiNames = Random.shuffle(Players.aiNames).map(name => name + " " + nameIteration)
|
||||
}
|
||||
if (players.exists(player => player.name == name)) {
|
||||
generateAiName()
|
||||
} else {
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all players using the given updater.
|
||||
* Note that you will need to notify clients about the changes if the updater doesn't.
|
||||
* @param update The player updater.
|
||||
*/
|
||||
def updatePlayers(update: (Player => Player)): Unit = {
|
||||
players = players.map(player => update(player))
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the given player using the given updater.
|
||||
* Note that you will need to notify clients about the changes if the updater doesn't.
|
||||
* @param update The player updater.
|
||||
* @throws BadRequestException with key "not-a-player" if the player doesn't exist.
|
||||
*/
|
||||
def updatePlayer(playerId: Player.Id, update: (Player => Player)): Unit = {
|
||||
val updated = update(getPlayer(playerId))
|
||||
players = players.map(player => if (player.id == playerId) updated else player)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the given player.
|
||||
* @param playerId The ID of the player to get.
|
||||
* @return The player.
|
||||
* @throws BadRequestException with the key "not-a-player" if the player doesn't exist.
|
||||
*/
|
||||
def getPlayer(playerId: Player.Id): Player = {
|
||||
players.find(player => player.id == playerId).getOrElse(throw BadRequestException(Errors.NotAPlayer()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the given player as back into the game. (Notifies all clients)
|
||||
* @param playerId The player to mark as back.
|
||||
* @throws BadRequestException with key "not-a-player" if the player doesn't exist.
|
||||
* @throws BadRequestException with key "not-being-skipped" if the player was not being skipped.
|
||||
*/
|
||||
def back(playerId: Player.Id): Unit = {
|
||||
val player = getPlayer(playerId)
|
||||
BadRequestException.verify(player.status == Player.Skipping, Errors.NotBeingSkipped())
|
||||
updatePlayer(playerId, setPlayerStatus(Player.Neutral, Player.Status.sticky - Player.Skipping))
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the given player as having left the game. (Notifies all clients)
|
||||
* @param playerId The player to mark as having left the game.
|
||||
* @throws BadRequestException with key "not-a-player" if the player doesn't exist.
|
||||
*/
|
||||
def leave(playerId: Player.Id): Unit = {
|
||||
updatePlayer(playerId, setPlayerLeft())
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the player as connected to the game, and mark them as such. (Notifies all clients)
|
||||
* @param playerId The player to mark as connected.
|
||||
* @throws BadRequestException with key "already-left-game" if the player is marked as having left the game.
|
||||
* @throws BadRequestException with key "not-a-player" if the player doesn't exist.
|
||||
*/
|
||||
def register(playerId: Player.Id): Unit = {
|
||||
val player = getPlayer(playerId)
|
||||
BadRequestException.verify(!player.left, Errors.AlreadyLeftGame())
|
||||
connected += playerId
|
||||
updatePlayer(playerId, setPlayerDisconnected(false))
|
||||
if (player.status == Player.Skipping) {
|
||||
back(playerId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the registration of the player as connected to the game.
|
||||
* @param playerId The player to remove registration for.
|
||||
*/
|
||||
def unregister(playerId: Player.Id): Unit = {
|
||||
connected -= playerId
|
||||
}
|
||||
|
||||
private def newId() = {
|
||||
val id = Player.Id(nextId)
|
||||
nextId += 1
|
||||
id
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given player can be the card czar (is human, hasn't left, isn't being skipped).
|
||||
* @param player The player to check.
|
||||
* @return If the player can be the card czar.
|
||||
*/
|
||||
def canBeCzar(player: Player): Boolean = {
|
||||
ais.exists(ai => ai.id == player.id) || player.left || player.status == Player.Skipping
|
||||
}
|
||||
|
||||
/**
|
||||
* @return The ids of every player that has been in the game.
|
||||
*/
|
||||
def ids = players.map(player => player.id)
|
||||
|
||||
/**
|
||||
* @return The number of players that have been in the game.
|
||||
*/
|
||||
def amount = players.length
|
||||
|
||||
/**
|
||||
* @return Players that are actively in the game (not left or being skipped)
|
||||
*/
|
||||
def activePlayers = players.filter(player => !player.left && player.status != Player.Skipping)
|
||||
|
||||
/**
|
||||
* An updater for players that changes their status. See updatePlayers()/updaterPlayer().
|
||||
* @param newStatus The status to set the player to.
|
||||
* @param ignoring If the player has a status in this set, do not change it. (Defaults to the sticky statuses).
|
||||
* @return An updater function for a player. (Notifies all clients)
|
||||
*/
|
||||
def setPlayerStatus(newStatus: Player.Status, ignoring: Set[Player.Status] = Player.Status.sticky): (Player => Player) = {
|
||||
player =>
|
||||
if (!player.left && !ignoring.contains(player.status) && player.status != newStatus) {
|
||||
notifiers.playerStatus(player.id, newStatus)
|
||||
player.copy(status = newStatus)
|
||||
} else {
|
||||
player
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An updater for players that changes their score. See updatePlayers()/updaterPlayer().
|
||||
* @param by The amount to change the score by.
|
||||
* @return An updater function for a player. (Notifies all clients)
|
||||
*/
|
||||
def modifyPlayerScore(by: Int): (Player => Player) = {
|
||||
player =>
|
||||
val newScore = player.score + by
|
||||
notifiers.playerScoreChange(player.id, newScore)
|
||||
player.copy(score = newScore)
|
||||
}
|
||||
|
||||
/**
|
||||
* An updater for players that marks them as having left the game. See updatePlayers()/updaterPlayer().
|
||||
* @return An updater function for a player. (Notifies all clients)
|
||||
*/
|
||||
def setPlayerLeft(): (Player => Player) = {
|
||||
player =>
|
||||
notifiers.playerLeft(player.id)
|
||||
if (player.status != Player.Neutral) {
|
||||
notifiers.playerStatus(player.id, Player.Neutral)
|
||||
}
|
||||
player.copy(status = Player.Neutral, left = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* An updater for players that marks them as being disconnected from the game. See updatePlayers()/updaterPlayer().
|
||||
* Note that we generally leave a grace period for clients to reconnect before marking them as disconnected.
|
||||
* @param disconnected If the player is connected or disconnected.
|
||||
* @return An updater function for a player. (Notifies all clients)
|
||||
*/
|
||||
def setPlayerDisconnected(disconnected: Boolean): (Player => Player) = {
|
||||
player =>
|
||||
if (!player.left && player.status != Player.Ai) {
|
||||
if (disconnected) {
|
||||
notifiers.playerDisconnect(player.id)
|
||||
} else {
|
||||
notifiers.playerReconnect(player.id)
|
||||
}
|
||||
player.copy(disconnected = disconnected)
|
||||
} else {
|
||||
player
|
||||
}
|
||||
}
|
||||
}
|
||||
object Players {
|
||||
/**
|
||||
* The minimum number of players that can be active in a game for it to start or continue.
|
||||
*/
|
||||
val minimum = 2
|
||||
|
||||
/**
|
||||
* The name of the first ai.
|
||||
*/
|
||||
val aiName = "Rando Cardrissian"
|
||||
|
||||
/**
|
||||
* Names for additional ais.
|
||||
*/
|
||||
val aiNames = List(
|
||||
"HAL 9000",
|
||||
"GLaDOS",
|
||||
"Wheatley",
|
||||
"TEC-XX",
|
||||
"EDI",
|
||||
"343 Guilty Spark",
|
||||
"Jarvis",
|
||||
"Deep Thought",
|
||||
"Gibson",
|
||||
"Skynet",
|
||||
"AI",
|
||||
"Computer",
|
||||
"Real Human"
|
||||
)
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package massivedecks.models
|
||||
|
||||
import play.api.libs.json.Json
|
||||
|
||||
/**
|
||||
* Errors models.
|
||||
*/
|
||||
object Errors {
|
||||
trait ErrorDetails {
|
||||
def error: String
|
||||
def toJson(): String
|
||||
}
|
||||
|
||||
trait SimpleErrorDetails extends ErrorDetails {
|
||||
def toJson() = Json.obj("error" -> error).toString()
|
||||
}
|
||||
|
||||
case class LobbyNotFound(error: String = "lobby-not-found") extends SimpleErrorDetails
|
||||
case class InvalidCommand(command: String, error: String = "invalid-command") extends ErrorDetails {
|
||||
def toJson() = Json.obj(
|
||||
"error" -> error,
|
||||
"command" -> command
|
||||
).toString()
|
||||
}
|
||||
case class InvalidName(error: String = "invalid-name") extends SimpleErrorDetails
|
||||
case class NameInUse(error: String = "name-in-use") extends SimpleErrorDetails
|
||||
case class BadlyFormedSecret(error: String = "badly-formed-secret") extends SimpleErrorDetails
|
||||
case class SecretWrongOrNotAPlayer(error: String = "secret-wrong-or-not-a-player") extends SimpleErrorDetails
|
||||
case class PasswordWrong(error: String = "password-wrong") extends SimpleErrorDetails
|
||||
case class NotAPlayer(error: String = "not-a-player") extends SimpleErrorDetails
|
||||
case class NotBeingSkipped(error: String = "not-being-skipped") extends SimpleErrorDetails
|
||||
case class AlreadyLeftGame(error: String = "already-left-game") extends SimpleErrorDetails
|
||||
case class CardcastTimeout(error: String = "cardcast-timeout") extends SimpleErrorDetails
|
||||
case class GameInProgress(error: String = "game-in-progress") extends SimpleErrorDetails
|
||||
case class NotEnoughPlayers(required: Int, error: String = "not-enough-players") extends ErrorDetails {
|
||||
def toJson() = Json.obj(
|
||||
"error" -> error,
|
||||
"required" -> required
|
||||
).toString()
|
||||
}
|
||||
case class PlayersMustBeSkippable(error: String = "players-must-be-skippable") extends SimpleErrorDetails
|
||||
case class RuleNotEnabled(error: String = "rule-not-enabled") extends SimpleErrorDetails
|
||||
case class NoGameInProgress(error: String = "no-game-in-progress") extends SimpleErrorDetails
|
||||
case class NotInRound(error: String = "not-in-round") extends SimpleErrorDetails
|
||||
case class AlreadyPlayed(error: String = "already-played") extends SimpleErrorDetails
|
||||
case class AlreadyJudging(error: String = "already-judging") extends SimpleErrorDetails
|
||||
case class InvalidCardId(error: String = "invalid-card-id") extends SimpleErrorDetails
|
||||
case class WrongNumberOfCardsPlayed(got: Int, expected: Int, error: String = "wrong-number-of-cards-played") extends ErrorDetails {
|
||||
def toJson() = Json.obj(
|
||||
"error" -> error,
|
||||
"got" -> got,
|
||||
"expected" -> expected
|
||||
).toString()
|
||||
}
|
||||
case class NotCzar(error: String = "not-czar") extends SimpleErrorDetails
|
||||
case class NotJudging(error: String = "not-judging") extends SimpleErrorDetails
|
||||
case class NoSuchPlayedCards(error: String = "no-such-played-cards") extends SimpleErrorDetails
|
||||
case class AlreadyJudged(error: String = "already-judged") extends SimpleErrorDetails
|
||||
case class NotEnoughPoints(error: String = "not-enough-points") extends SimpleErrorDetails
|
||||
case class InvalidDeckConfiguration(reason: String, error: String = "invalid-deck-configuration") extends ErrorDetails {
|
||||
def toJson() = Json.obj(
|
||||
"error" -> error,
|
||||
"reason" -> reason
|
||||
).toString()
|
||||
}
|
||||
case class DeckNotFound(error: String = "deck-not-found") extends SimpleErrorDetails
|
||||
case class NotOwner(error: String = "not-owner") extends SimpleErrorDetails
|
||||
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
package massivedecks.models
|
||||
|
||||
import play.api.libs.json._
|
||||
import massivedecks.models.Player.Formatters._
|
||||
|
||||
/**
|
||||
* Created by gareth on 30/06/16.
|
||||
*/
|
||||
object Game {
|
||||
|
||||
sealed trait Card
|
||||
|
||||
case class Call(id: String, parts: List[String]) extends Card {
|
||||
require(parts.length > 1, "A call must have at least one slot.")
|
||||
|
||||
def slots: Int = parts.length - 1
|
||||
|
||||
def withResponses(responses: List[Response]): String =
|
||||
intersperse(parts, responses.map(response => response.text)).mkString("")
|
||||
}
|
||||
|
||||
case class Response(id: String, text: String) extends Card
|
||||
|
||||
case class DeckInfo(id: String, name: String, calls: Int, responses: Int)
|
||||
|
||||
case class Config(decks: List[DeckInfo], houseRules: Set[String], password: Option[String])
|
||||
|
||||
sealed trait State {
|
||||
def gameState: String
|
||||
}
|
||||
object State {
|
||||
case class Configuring() extends State {
|
||||
override val gameState = "configuring"
|
||||
}
|
||||
case class Playing(round: Round) extends State {
|
||||
override val gameState = "playing"
|
||||
}
|
||||
case class Finished() extends State {
|
||||
override val gameState = "finished"
|
||||
}
|
||||
}
|
||||
|
||||
case class Round(czar: Player.Id, call: Call, state: Round.State)
|
||||
object Round {
|
||||
sealed trait State {
|
||||
def afterTimeLimit: Boolean
|
||||
def roundState: String
|
||||
|
||||
def isPlaying: Boolean = this.isInstanceOf[State.Playing]
|
||||
def isJudging: Boolean = this.isInstanceOf[State.Judging]
|
||||
def isFinished: Boolean = this.isInstanceOf[State.Finished]
|
||||
}
|
||||
object State {
|
||||
case class Playing(override val afterTimeLimit: Boolean, numberPlayed: Int) extends State {
|
||||
override val roundState = "playing"
|
||||
}
|
||||
|
||||
case class Judging(override val afterTimeLimit: Boolean, cards: List[List[Response]]) extends State {
|
||||
override val roundState = "judging"
|
||||
}
|
||||
|
||||
case class Finished(cards: List[List[Response]], playedByAndWinner: PlayedByAndWinner) extends State {
|
||||
override val roundState = "finished"
|
||||
override val afterTimeLimit = false
|
||||
}
|
||||
}
|
||||
case class Finished(czar: Player.Id, call: Call, state: Round.State.Finished)
|
||||
}
|
||||
|
||||
case class PlayedByAndWinner(playedBy: List[Player.Id], winner: Player.Id)
|
||||
|
||||
case class Hand(hand: List[Response])
|
||||
object Hand {
|
||||
val size: Int = 10
|
||||
val extraDrawAfter: Int = 2
|
||||
}
|
||||
|
||||
object Formatters {
|
||||
implicit val callFormat: Format[Call] = Json.format[Call]
|
||||
implicit val responseFormat: Format[Response] = Json.format[Response]
|
||||
implicit val playedByAndWinnerFormat: Format[PlayedByAndWinner] = Json.format[PlayedByAndWinner]
|
||||
implicit val deckInfoFormat: Format[DeckInfo] = Json.format[DeckInfo]
|
||||
implicit val configFormat: Format[Config] = Json.format[Config]
|
||||
implicit val handFormat: Format[Hand] = Json.format[Hand]
|
||||
|
||||
private val playingRoundStateFormat: Format[Round.State.Playing] = Json.format[Round.State.Playing]
|
||||
private val judgingRoundStateFormat: Format[Round.State.Judging] = Json.format[Round.State.Judging]
|
||||
private val finishedRoundStateFormat: Format[Round.State.Finished] = Json.format[Round.State.Finished]
|
||||
implicit val roundStateWrites: Writes[Round.State] = Writes { state: Round.State =>
|
||||
(state match {
|
||||
case state: Round.State.Playing =>
|
||||
playingRoundStateFormat.writes(state)
|
||||
|
||||
case state: Round.State.Judging =>
|
||||
judgingRoundStateFormat.writes(state)
|
||||
|
||||
case state: Round.State.Finished =>
|
||||
finishedRoundStateFormat.writes(state)
|
||||
}).as[JsObject] + ("roundState" -> Json.toJson(state.roundState))
|
||||
}
|
||||
implicit val roundWrites: Writes[Round] = Json.writes[Round]
|
||||
implicit val finishedRoundWrites: Writes[Round.Finished] = Json.writes[Round.Finished]
|
||||
|
||||
private val playingGameStateFormat: Writes[State.Playing] = Json.writes[State.Playing]
|
||||
implicit val gameStateWrites: Writes[State] = Writes { state: State =>
|
||||
(state match {
|
||||
case state: State.Configuring => JsObject(List())
|
||||
case state: State.Playing => playingGameStateFormat.writes(state)
|
||||
case state: State.Finished => JsObject(List())
|
||||
}).as[JsObject] + ("gameState" -> Json.toJson(state.gameState))
|
||||
}
|
||||
}
|
||||
|
||||
private def intersperse[A](a : List[A], b : List[A]): List[A] = a match {
|
||||
case first :: rest => first :: intersperse(b, rest)
|
||||
case _ => b
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package massivedecks.models
|
||||
|
||||
import play.api.libs.json.{Json, Writes}
|
||||
import massivedecks.models.Game.Formatters._
|
||||
import massivedecks.models.Player.Formatters._
|
||||
|
||||
/**
|
||||
* Created by gareth on 30/06/16.
|
||||
*/
|
||||
object Lobby {
|
||||
|
||||
case class Lobby(gameCode: String, owner: Player.Id, config: Game.Config, players: List[Player], state: Game.State)
|
||||
|
||||
case class LobbyAndHand(lobby: Lobby, hand: Game.Hand)
|
||||
|
||||
case class GameCodeAndSecret(gameCode: String, secret: Player.Secret)
|
||||
|
||||
object Formatters {
|
||||
implicit val lobbyWrites: Writes[Lobby] = Json.writes[Lobby]
|
||||
implicit val lobbyAndHandWrites: Writes[LobbyAndHand] = Json.writes[LobbyAndHand]
|
||||
implicit val gameCodeAndSecretWrites: Writes[GameCodeAndSecret] = Json.writes[GameCodeAndSecret]
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package massivedecks.models
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
import play.api.libs.json._
|
||||
|
||||
case class Player(id: Player.Id, name: String, status: Player.Status = Player.Neutral, score: Int = 0, disconnected: Boolean = false, left: Boolean = false) {
|
||||
require(!name.isEmpty, "Name can't be empty!")
|
||||
}
|
||||
object Player {
|
||||
|
||||
case class Id(id: Int) extends AnyVal
|
||||
|
||||
sealed trait Status {
|
||||
val name: String
|
||||
}
|
||||
object Status {
|
||||
private val types = List(NotPlayed, Played, Czar, Ai, Neutral, Skipping)
|
||||
val fromName: Map[String, Status] = (for (status <- types) yield status.name -> status).toMap
|
||||
val notInRound: Set[Status] = Set(Skipping, Czar)
|
||||
val sticky: Set[Status] = Set(Ai, Skipping)
|
||||
}
|
||||
case object NotPlayed extends Status {
|
||||
val name = "not-played"
|
||||
}
|
||||
case object Played extends Status {
|
||||
val name = "played"
|
||||
}
|
||||
case object Czar extends Status {
|
||||
val name = "czar"
|
||||
}
|
||||
case object Ai extends Status {
|
||||
val name = "ai"
|
||||
}
|
||||
case object Neutral extends Status {
|
||||
val name = "neutral"
|
||||
}
|
||||
case object Skipping extends Status {
|
||||
val name = "skipping"
|
||||
}
|
||||
|
||||
case class Secret(id: Player.Id, secret: String = UUID.randomUUID().toString)
|
||||
|
||||
object Formatters {
|
||||
implicit val idFormat: Format[Id] = Format(
|
||||
Reads(num => num.validate[Int].map(Id)), Writes(id => JsNumber(id.id)))
|
||||
implicit val statusFormat : Format[Status] = Format(
|
||||
Reads(str => str.validate[String].map(Status.fromName)),
|
||||
Writes(status => JsString(status.name)))
|
||||
implicit val secretFormat: Format[Secret] = Json.format[Secret]
|
||||
implicit val playerFormat: Format[Player] = Json.format[Player]
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package massivedecks.models.cardcast
|
||||
|
||||
import massivedecks.models.Game
|
||||
import Game.{Call, DeckInfo, Response}
|
||||
|
||||
case class CardcastDeck(id: String, name: String, calls: List[Call], responses: List[Response]) {
|
||||
def info: DeckInfo = DeckInfo(id, name, calls.length, responses.length)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package massivedecks.notifications
|
||||
|
||||
import scala.concurrent.ExecutionContext
|
||||
|
||||
import massivedecks.models.Player
|
||||
import Player.Formatters._
|
||||
import play.api.libs.iteratee.Concurrent.Channel
|
||||
import play.api.libs.iteratee._
|
||||
import play.api.libs.json.Json
|
||||
|
||||
/**
|
||||
* Handles websocket notifications.
|
||||
*/
|
||||
class Notifier (implicit context: ExecutionContext) {
|
||||
private var unicastChannel: Option[Channel[String]] = None
|
||||
|
||||
/**
|
||||
* Call when a socket has been opened for the notifier.
|
||||
* @param onConnect The callback when the socket connects.
|
||||
* @param onIdentify The callback when the client identifies down the socket.
|
||||
* @param onClose The callback when the socket closes.
|
||||
* @return The iteratee and enumerator for the socket.
|
||||
*/
|
||||
def openedSocket(onConnect: () => Unit, onIdentify: (Player.Secret) => Unit, onClose: () => Unit): (Iteratee[String, Unit], Enumerator[String]) = {
|
||||
val iteratee = Notifier.forEachAndOnClose[String](
|
||||
message => onIdentify(Json.parse(message).validate[Player.Secret].get),
|
||||
onClose
|
||||
)
|
||||
val enumerator = Concurrent.unicast[String](
|
||||
channel => {
|
||||
unicastChannel = Some(channel)
|
||||
onConnect()
|
||||
}
|
||||
)
|
||||
(iteratee, enumerator)
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the client down the socket.
|
||||
* @param message The message to send.
|
||||
*/
|
||||
def notify(message: String) =
|
||||
unicastChannel.foreach { channel =>
|
||||
channel.push(message)
|
||||
}
|
||||
}
|
||||
object Notifier {
|
||||
def forEachAndOnClose[E](onMessage: (E) => Unit, onClose: () => Unit): Iteratee[E, Unit] = {
|
||||
def cont: Iteratee[E, Unit] = Cont {
|
||||
case Input.EOF =>
|
||||
onClose()
|
||||
Done((), Input.EOF)
|
||||
case Input.El(message) =>
|
||||
onMessage(message)
|
||||
cont
|
||||
case Input.Empty =>
|
||||
cont
|
||||
}
|
||||
cont
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
package massivedecks.notifications
|
||||
|
||||
import scala.concurrent.ExecutionContext
|
||||
|
||||
import massivedecks.models.{Game, Lobby, Player}
|
||||
import Game.Formatters._
|
||||
import Player.Formatters._
|
||||
import Lobby.Formatters._
|
||||
import play.api.libs.iteratee.{Concurrent, Enumerator, Iteratee}
|
||||
import play.api.libs.json.{JsValue, Json}
|
||||
|
||||
/**
|
||||
* Manages open sockets.
|
||||
*/
|
||||
class Notifiers (implicit context: ExecutionContext) {
|
||||
|
||||
private val (broadcastEnumerator, broadcastChannel) = Concurrent.broadcast[String]
|
||||
private var identified: Map[Player.Id, Notifier] = Map()
|
||||
|
||||
/**
|
||||
* Sets up notifications for a new websocket.
|
||||
*
|
||||
* @param onIdentify When we get an identification message from the client, this will be run. This should validate
|
||||
* the secret and throw a ForbiddenException if it isn't correct.
|
||||
* @param onClose When a client that has previously identified disconnects, this will be run.
|
||||
* @return The websocket iteratee and enumerator.
|
||||
*/
|
||||
def openedSocket(onIdentify: Player.Secret => Lobby.LobbyAndHand, onClose: (Player.Id => Unit)): (Iteratee[String, Unit], Enumerator[String]) = {
|
||||
val notifier = new Notifier()
|
||||
val (unicastIteratee, unicastEnumerator) = notifier.openedSocket(
|
||||
() => notifier.notify("identify"),
|
||||
(secret) => {
|
||||
val lobbyAndHand = onIdentify(secret)
|
||||
identified += (secret.id -> notifier)
|
||||
sync(secret.id, lobbyAndHand)
|
||||
},
|
||||
() => {
|
||||
identified.find(item => notifier == item._2).foreach { item =>
|
||||
val (id, _) = item
|
||||
identified -= id
|
||||
onClose(id)
|
||||
}
|
||||
}
|
||||
)
|
||||
(unicastIteratee, unicastEnumerator.interleave(broadcastEnumerator))
|
||||
}
|
||||
|
||||
|
||||
def sync(playerId: Player.Id, lobbyAndHand: Lobby.LobbyAndHand): Unit =
|
||||
notify(playerId, Json.obj(
|
||||
"event" -> "Sync",
|
||||
"lobbyAndHand" -> Json.toJson(lobbyAndHand)
|
||||
))
|
||||
|
||||
|
||||
def playerJoin(player: Player): Unit =
|
||||
notifyAll(Json.obj(
|
||||
"event" -> "PlayerJoin",
|
||||
"player" -> Json.toJson(player)
|
||||
))
|
||||
|
||||
def playerStatus(playerId: Player.Id, status: Player.Status): Unit =
|
||||
notifyAll(Json.obj(
|
||||
"event" -> "PlayerStatus",
|
||||
"player" -> Json.toJson(playerId),
|
||||
"status" -> Json.toJson(status)
|
||||
))
|
||||
|
||||
def playerLeft(playerId: Player.Id): Unit =
|
||||
notifyAll(Json.obj(
|
||||
"event" -> "PlayerLeft",
|
||||
"player" -> Json.toJson(playerId)
|
||||
))
|
||||
|
||||
def playerDisconnect(playerId: Player.Id): Unit =
|
||||
notifyAll(Json.obj(
|
||||
"event" -> "PlayerDisconnect",
|
||||
"player" -> Json.toJson(playerId)
|
||||
))
|
||||
|
||||
def playerReconnect(playerId: Player.Id): Unit =
|
||||
notifyAll(Json.obj(
|
||||
"event" -> "PlayerReconnect",
|
||||
"player" -> Json.toJson(playerId)
|
||||
))
|
||||
|
||||
def playerScoreChange(playerId: Player.Id, score: Int): Unit =
|
||||
notifyAll(Json.obj(
|
||||
"event" -> "PlayerScoreChange",
|
||||
"player" -> Json.toJson(playerId),
|
||||
"score" -> Json.toJson(score)
|
||||
))
|
||||
|
||||
|
||||
def handChange(playerId: Player.Id, hand: Game.Hand): Unit =
|
||||
notify(playerId, Json.obj(
|
||||
"event" -> "HandChange",
|
||||
"hand" -> Json.toJson(hand)
|
||||
))
|
||||
|
||||
|
||||
def roundStart(czar: Player.Id, call: Game.Call): Unit =
|
||||
notifyAll(Json.obj(
|
||||
"event" -> "RoundStart",
|
||||
"czar" -> Json.toJson(czar),
|
||||
"call" -> Json.toJson(call)
|
||||
))
|
||||
|
||||
def roundPlayed(playedCards: Int): Unit =
|
||||
notifyAll(Json.obj(
|
||||
"event" -> "RoundPlayed",
|
||||
"playedCards" -> Json.toJson(playedCards)
|
||||
))
|
||||
|
||||
def roundJudging(playedCards: List[List[Game.Response]]): Unit =
|
||||
notifyAll(Json.obj(
|
||||
"event" -> "RoundJudging",
|
||||
"playedCards" -> Json.toJson(playedCards)
|
||||
))
|
||||
|
||||
def roundEnd(finishedRound: Game.Round.Finished): Unit =
|
||||
notifyAll(Json.obj(
|
||||
"event" -> "RoundEnd",
|
||||
"finishedRound" -> Json.toJson(finishedRound)
|
||||
))
|
||||
|
||||
|
||||
def gameStart(czar: Player.Id, call: Game.Call): Unit =
|
||||
notifyAll(Json.obj(
|
||||
"event" -> "GameStart",
|
||||
"czar" -> Json.toJson(czar),
|
||||
"call" -> Json.toJson(call)
|
||||
))
|
||||
|
||||
def gameEnd(): Unit =
|
||||
notifyAll(Json.obj(
|
||||
"event" -> "GameEnd"
|
||||
))
|
||||
|
||||
|
||||
def configChange(config: Game.Config): Unit =
|
||||
notifyAll(Json.obj(
|
||||
"event" -> "ConfigChange",
|
||||
"config" -> Json.toJson(config)
|
||||
))
|
||||
|
||||
|
||||
def roundTimeLimitHit(): Unit =
|
||||
notifyAll(Json.obj(
|
||||
"event" -> "RoundTimeLimitHit"
|
||||
))
|
||||
|
||||
|
||||
private def notify(player: Player.Id, event: JsValue) =
|
||||
identified.get(player).foreach { notifier => notifier.notify(event.toString) }
|
||||
|
||||
private def notifyAll(event: JsValue) =
|
||||
broadcastChannel.push(event.toString)
|
||||
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package massivedecks.stores
|
||||
|
||||
import scala.concurrent.duration._
|
||||
import java.util.concurrent.locks.{Lock, ReentrantLock}
|
||||
|
||||
import massivedecks.exceptions.NotFoundException
|
||||
import massivedecks.lobby.Lobby
|
||||
import massivedecks.models.Errors
|
||||
import play.api.cache.CacheApi
|
||||
|
||||
/**
|
||||
* This trait provides a framework for a LobbyStore with caching and locking.
|
||||
*/
|
||||
trait CachedStore extends LobbyStore {
|
||||
|
||||
protected val lobbyFactory: Lobby.Factory
|
||||
protected val gameCodeManager: GameCodeManager
|
||||
protected val cache: CacheApi
|
||||
|
||||
protected var locks: Map[String, Lock] = Map()
|
||||
|
||||
/**
|
||||
* Get the lobby for the given game code, or None if it doesn't exist.
|
||||
*
|
||||
* @param gameCode The game code for the lobby.
|
||||
* @return The lobby or None.
|
||||
*/
|
||||
protected def getLobby(gameCode: String): Option[Lobby]
|
||||
|
||||
/**
|
||||
* Save the lobby into storage - overwriting existing ones if found.
|
||||
*
|
||||
* @param lobby The lobby to save.
|
||||
*/
|
||||
protected def storeLobby(lobby: Lobby): Unit
|
||||
|
||||
override def newLobby(ownerName: String): String = {
|
||||
val gameCode = gameCodeManager.generate()
|
||||
val lock = lockFor(gameCode)
|
||||
try {
|
||||
val lobby = lobbyFactory.build(gameCode, ownerName)
|
||||
saveLobby(lobby)
|
||||
} finally {
|
||||
lock.unlock()
|
||||
}
|
||||
gameCode
|
||||
}
|
||||
|
||||
private def saveLobby(lobby: Lobby): Unit = {
|
||||
cache.set(lobby.gameCode, lobby, CachedStore.cacheTimeout)
|
||||
storeLobby(lobby)
|
||||
}
|
||||
|
||||
override def readFromLobby[Result](gameCode: String)(readAction: Lobby => Result): Result =
|
||||
performInLobby(gameCode)(readAction)
|
||||
|
||||
override def performInLobby[Result](gameCode: String)(action: Lobby => Result): Result = {
|
||||
val lock = lockFor(gameCode)
|
||||
cache.get(gameCode).orElse { getLobby(gameCode) } match {
|
||||
case Some(lobby) =>
|
||||
try {
|
||||
try {
|
||||
action(lobby)
|
||||
} finally {
|
||||
saveLobby(lobby)
|
||||
}
|
||||
} finally {
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
case None =>
|
||||
lock.unlock()
|
||||
throw NotFoundException(Errors.LobbyNotFound())
|
||||
}
|
||||
}
|
||||
|
||||
private def lockFor(gameCode: String): Lock = {
|
||||
locks.get(gameCode) match {
|
||||
case Some(lock) =>
|
||||
lock.lock()
|
||||
lock
|
||||
|
||||
case None =>
|
||||
val lock = new ReentrantLock()
|
||||
lock.lock()
|
||||
locks += gameCode -> lock
|
||||
lock
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
object CachedStore {
|
||||
|
||||
/**
|
||||
* How long after being saved lobbies will fall out of the cache.
|
||||
*/
|
||||
val cacheTimeout = 1.minutes
|
||||
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package massivedecks.stores
|
||||
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
import org.hashids.Hashids
|
||||
|
||||
/**
|
||||
* A generator for game codes.
|
||||
*/
|
||||
class GameCodeManager(state: AtomicLong) {
|
||||
private val gameCodeEncoder = Hashids.reference("massivedecks", 0, "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
|
||||
|
||||
def generate() = gameCodeEncoder.encode(state.incrementAndGet())
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package massivedecks.stores
|
||||
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import javax.inject.Inject
|
||||
|
||||
import massivedecks.lobby.Lobby
|
||||
import play.api.cache.CacheApi
|
||||
|
||||
case class InMemoryStore @Inject() (lobbyFactory: Lobby.Factory, cache: CacheApi) extends CachedStore {
|
||||
override protected val gameCodeManager: GameCodeManager = new GameCodeManager(new AtomicLong(0))
|
||||
|
||||
private var lobbies: Map[String, Lobby] = Map()
|
||||
|
||||
/**
|
||||
* Save the lobby into storage - overwriting existing ones if found.
|
||||
*
|
||||
* @param lobby The lobby to save.
|
||||
*/
|
||||
override protected def storeLobby(lobby: Lobby): Unit = lobbies += lobby.gameCode -> lobby
|
||||
|
||||
/**
|
||||
* Get the lobby for the given game code, or None if it doesn't exist.
|
||||
*
|
||||
* @param gameCode The game code for the lobby.
|
||||
* @return The lobby or None.
|
||||
*/
|
||||
override protected def getLobby(gameCode: String): Option[Lobby] = lobbies.get(gameCode)
|
||||
|
||||
/**
|
||||
* Remove the given lobby from storage.
|
||||
*
|
||||
* @param gameCode The game code for the lobby.
|
||||
*/
|
||||
override def removeLobby(gameCode: String): Unit = lobbies -= gameCode
|
||||
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package massivedecks.stores
|
||||
|
||||
import massivedecks.lobby.Lobby
|
||||
|
||||
/**
|
||||
* A lobby store is used to store the game data from lobbies.
|
||||
*/
|
||||
trait LobbyStore {
|
||||
|
||||
/**
|
||||
* Make a new lobby.
|
||||
* @param ownerName The name of the owner (the player making the lobby).
|
||||
* @return The game code for the new lobby.
|
||||
*/
|
||||
def newLobby(ownerName: String): String
|
||||
|
||||
/**
|
||||
* Perform a read-only action in the lobby.
|
||||
* This should lock the lobby so other reads can't happen.
|
||||
* @param gameCode The game code for the lobby.
|
||||
* @param readAction The action to read from the lobby.
|
||||
* @tparam Result The type of the result of the read.
|
||||
* @return The result of the read.
|
||||
*/
|
||||
def readFromLobby[Result](gameCode: String)(readAction: Lobby => Result): Result
|
||||
|
||||
/**
|
||||
* Perform an action inside the lobby, saving the resulting lobby.
|
||||
* This should lock the lobby so other writes and reads can't happen.
|
||||
* @param gameCode The game code for the lobby.
|
||||
* @param action The action to perform in the lobby.
|
||||
* @tparam Result The type of the result of the action.
|
||||
* @return The result of the action.
|
||||
*/
|
||||
def performInLobby[Result](gameCode: String)(action: Lobby => Result): Result
|
||||
|
||||
/**
|
||||
* Remove the given lobby from storage.
|
||||
* @param gameCode The game code for the lobby.
|
||||
*/
|
||||
def removeLobby(gameCode: String): Unit
|
||||
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
@(url: String, version: String, statusCode: Int, errorMessage: String, errorDescription: String, bugReportBody: String)<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@head(s"$errorMessage - Massive Decks", url)
|
||||
<link rel="stylesheet" href="@routes.Assets.versioned("stylesheets/error.css")">
|
||||
</head>
|
||||
<body>
|
||||
<div id="cards">
|
||||
<div class="card call mui-panel">
|
||||
<div class="call-text">
|
||||
<span>@url</span>
|
||||
<span class="slot"></span>
|
||||
</div>
|
||||
</div>
|
||||
<ol>
|
||||
<li>
|
||||
<div id="status-card" class="card response mui-panel">
|
||||
<div class="response-text">
|
||||
<span>@statusCode.</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div id="message-card" class="card response mui-panel">
|
||||
<div class="response-text">
|
||||
<span>@errorMessage.</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div id="advice" class="mui-panel">
|
||||
<h1>@errorMessage</h1>
|
||||
<p>
|
||||
Unfortunately, @errorDescription. You probably want to go back to the <a href="@url">main page</a>.
|
||||
</p>
|
||||
<p>
|
||||
If you think this is a bug in Massive Decks, please
|
||||
<a href="https://github.com/Lattyware/massivedecks/issues/new?body=@bugReportBody">submit a bug report</a>.
|
||||
</p>
|
||||
</div>
|
||||
<a id="logo" href="@url"><img src="@routes.Assets.versioned("images/icon.svg")" alt="The Massive Decks logo." title="Massive Decks" "></a>
|
||||
@if(version.nonEmpty) {
|
||||
<p id="version">Version @version</p>
|
||||
}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,23 +0,0 @@
|
||||
@(title: String, url: String)<meta charset="utf-8">
|
||||
|
||||
<title>@title</title>
|
||||
<link rel="canonical" href="@url">
|
||||
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<script src="@routes.Assets.versioned("javascripts/mui.min.js")"></script>
|
||||
<link rel="stylesheet" href="@routes.Assets.versioned("stylesheets/mui.min.css")">
|
||||
|
||||
<link rel="icon" type="image/png" href="/icon.png" sizes="192x192">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="theme-color" content="#2196F3">
|
||||
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-title" content="Massive Decks">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#2196f3">
|
||||
|
||||
<meta name="msapplication-TileImage" content="mstile.png">
|
||||
<meta name="msapplication-TileColor" content="#e5e5e5">
|
||||
@@ -1,20 +0,0 @@
|
||||
@(url: String, version: String)<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@head("Massive Decks", url)
|
||||
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"
|
||||
integrity="sha256-eZrrJcwDc/3uDhsdt61sL2oOBY362qM3lon1gyExkL0=" crossorigin="anonymous" />
|
||||
|
||||
<script src="@routes.Assets.versioned("javascripts/qrcode.min.js")"></script>
|
||||
|
||||
<link rel="stylesheet" href="@routes.Assets.versioned("stylesheets/massivedecks.css")">
|
||||
<script src="@routes.Assets.versioned("javascripts/util.js")"></script>
|
||||
</head>
|
||||
<body>
|
||||
<script src="@routes.Assets.versioned("javascripts/MassiveDecks.js")"></script>
|
||||
<script>
|
||||
start("@url", "@version");
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,23 +0,0 @@
|
||||
name := "massivedecks"
|
||||
|
||||
version := "dev"
|
||||
|
||||
lazy val root = (project in file(".")).enablePlugins(PlayScala)
|
||||
|
||||
scalaVersion := "2.11.8"
|
||||
|
||||
resolvers += "scalaz-bintray" at "http://dl.bintray.com/scalaz/releases"
|
||||
resolvers += "dl-john-ky" at "http://dl.john-ky.io/maven/releases"
|
||||
|
||||
libraryDependencies ++= Seq(
|
||||
cache,
|
||||
ws,
|
||||
|
||||
"io.john-ky" %% "hashids-scala" % "1.1.1-7d841a8",
|
||||
|
||||
specs2 % Test
|
||||
)
|
||||
|
||||
includeFilter in (Assets, LessKeys.less) := "massivedecks.less" | "error.less"
|
||||
|
||||
routesGenerator := InjectedRoutesGenerator
|
||||
@@ -0,0 +1,4 @@
|
||||
.idea/
|
||||
dist/
|
||||
elm-stuff/
|
||||
node_modules/
|
||||
@@ -0,0 +1,31 @@
|
||||
FROM node:11-alpine
|
||||
|
||||
WORKDIR /md
|
||||
|
||||
COPY ["./src", "./src"]
|
||||
COPY ["./assets", "./assets"]
|
||||
COPY ["./package.json", "./package-lock.json", "./tsconfig.json", "./webpack.config.js", "./elm.json", "./"]
|
||||
RUN ["npm", "ci"]
|
||||
RUN ["npm", "run", "build"]
|
||||
|
||||
FROM nginx:mainline-alpine
|
||||
LABEL maintainer="team@rereadgames.com"
|
||||
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
ARG VERSION
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
LABEL org.label-schema.name="massivedecks/client"
|
||||
LABEL org.label-schema.description="The web client for Massive Decks, a comedy party game."
|
||||
LABEL org.label-schema.url="https://github.com/Lattyware/massivedecks"
|
||||
LABEL org.label-schema.vcs-url="https://github.com/Lattyware/massivedecks"
|
||||
LABEL org.label-schema.vendor="Reread Games"
|
||||
LABEL org.label-schema.schema-version="1.0"
|
||||
LABEL org.label-schema.build-date=$BUILD_DATE
|
||||
LABEL org.label-schema.vcs-ref=$VCS_REF
|
||||
LABEL org.label-schema.version=$VERSION
|
||||
|
||||
COPY --from=0 ["/md/dist", "/usr/share/nginx/html/"]
|
||||
COPY ["./nginx.conf", "/etc/nginx/nginx.conf"]
|
||||
@@ -0,0 +1 @@
|
||||
<svg width="52" height="26" viewBox="0 0 52 26" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><g fill="#42a5f5" fill-opacity="0.2"><path d="M10 10c0-2.21-1.79-4-4-4-3.314 0-6-2.686-6-6h2c0 2.21 1.79 4 4 4 3.314 0 6 2.686 6 6 0 2.21 1.79 4 4 4 3.314 0 6 2.686 6 6 0 2.21 1.79 4 4 4v2c-3.314 0-6-2.686-6-6 0-2.21-1.79-4-4-4-3.314 0-6-2.686-6-6zm25.464-1.95l8.486 8.486-1.414 1.414-8.486-8.486 1.414-1.414z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 445 B |
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path d="M318.24805.00195313c-6.19363-.09305274-12.46719 2.16256247-17.25782 7.22656247L256 53.570312 211.01172 7.2285156c-12.63-13.351-36.04817-7.23403122-40.32617 10.6679684l-15.98438 62.671875-61.994139-17.667968c-17.837-4.994-34.650156 11.837875-29.660156 29.671875l17.660156 62.015624-62.65039 15.98828c-17.98800037 4.301-24.3660629 27.36689-10.6640629 40.33789l45.9843749 45.00586-45.9843749 45.00391c-13.708 12.977-7.31593748 36.03984 10.6640629 40.33984l62.648437 15.99024-17.660156 62.01367c-4.991 17.838 11.828156 34.66387 29.660156 29.67187l61.994142-17.66797 15.98437 62.67188c4.439 18.575 27.69718 24.01797 40.32618 10.66797L256 458.60938l44.98828 46.00195c12.5 13.488 35.98817 7.48603 40.32617-10.66797l15.98438-62.67188 61.99414 17.66797c17.836 4.994 34.65016-11.83787 29.66015-29.67187l-17.66015-62.01367 62.64844-15.99024c17.987-4.302 24.36606-27.36784 10.66406-40.33984l-45.98438-45.00391 45.98633-45.00586c13.708-12.977 7.31594-36.03789-10.66406-40.33789l-62.65039-15.99023 17.66015-62.015628c4.991-17.838-11.82815-34.661921-29.66015-29.669921l-61.99414 17.666015-15.98438-62.669922C338.6707 6.8346094 328.57075.15704102 318.24805.00195312zM274.66016 170.69922c7.99688 0 23.99023 2.66618 23.99023 27.14062 0 14.13314-8.65317 22.06093-11.08789 31.50391h33.89453c11.12799 0 19.78982 9.24599 19.8418 19.35937.028 5.977-2.51378 12.41147-6.47656 16.39258l-.03711.03516c3.27738 7.77596 2.74448 18.67286-3.10157 26.48047 2.89254 8.6283-.02149 19.22608-5.45703 24.9082 1.43211 5.86371.74741 10.85366-2.04883 14.87109-6.80063 9.77081-23.65671 9.91016-37.91015 9.91016l-.94727-.00195c-16.08939-.006-29.25736-5.86315-39.83789-10.57031-5.31693-2.36541-12.26836-5.29367-17.54297-5.39063-2.17915-.04-3.92773-1.81857-3.92773-3.99805v-71.22851c0-1.06625.42718-2.09014 1.18555-2.83985 13.19951-13.0429 18.87653-26.8504 29.69531-37.6875 4.93274-4.94207 6.72661-12.40777 8.46094-19.62695 1.48175-6.16459 4.5816-19.25781 11.30664-19.25781zm-95.96289 74.63867h26.65625c4.41662 0 7.99609 3.57949 7.99609 7.99609v79.96875c0 4.41661-3.57947 7.99805-7.99609 7.99805h-26.65625c-4.41661 0-7.99805-3.58144-7.99805-7.99805v-79.96875c0-4.4166 3.58144-7.99609 7.99805-7.99609zm13.32812 66.64063c-4.41661 0-7.99805 3.57948-7.99805 7.99609 0 4.41661 3.58144 7.99805 7.99805 7.99805s7.99609-3.58144 7.99609-7.99805-3.57948-7.99609-7.99609-7.99609z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="376" version="1">
|
||||
<path stroke="#fff" stroke-width="8" d="M10 89l246-79 80 246-247 80z"/>
|
||||
<path fill="#fff" stroke="#000" stroke-width="8" d="M146 63h239v238H146z"/>
|
||||
<path fill="#fff" stroke="#000" stroke-width="8" d="M279 63l223 76-72 227-227-76z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 379 B |
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" version="1">
|
||||
<path d="M273 20c-11 0-21 9-23 21l-9 88h-8L39 163c-12 2-21 15-19 27l50 283c2 12 15 21 27 19l194-34c12-3 21-15 18-27l-13-73 140 14c13 2 25-8 26-20l30-286c1-13-8-24-21-25L276 20h-3zm0 16h1l196 21c4 0 6 3 6 7l-30 286c0 4-4 7-8 6l-144-15-35-193-3-9 10-97c1-3 4-6 7-6zm47 106l-10 99 39 4c15 2 25 0 32-8 9-8 15-21 16-37 2-15-1-29-8-39-6-9-15-13-30-15zm19 20l18 1c16 2 22 14 20 35-3 22-11 32-27 31l-18-2zm-135 91l17 98-20 4-13-77-4 80-20 3-32-73 14 76-20 4-18-98 31-6 31 75 4-81z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 641 B |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" version="1">
|
||||
<path d="M258 16c-62 0-124 24-171 71-95 94-94 246 0 339 95 93 250 93 345 0l12-12-15-14-12 12-10-10c-82 80-213 80-295 0-82-81-82-210-1-291 81-80 212-81 294-3l-38 35 129 25-1-3-26-117-39 36c-48-46-110-68-172-68zm-79 159c-46 1-70 10-70 10v133s24-9 70-9c50 0 70 20 70 20V196s-20-21-70-21zm160 0c-50 0-70 21-70 21v133s20-20 70-20c46 0 70 9 70 9V185s-24-9-70-10zM179 323c-46 1-70 10-70 10v16s24-9 70-9c50 0 81 31 81 31s30-31 80-31c46 0 70 9 70 9v-16s-24-9-70-10c-50 0-80 32-80 32s-31-32-81-32z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 655 B |
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"type": "application",
|
||||
"source-directories": ["./src/elm"],
|
||||
"elm-version": "0.19.0",
|
||||
"dependencies": {
|
||||
"direct": {
|
||||
"elm/browser": "1.0.1",
|
||||
"elm/core": "1.0.2",
|
||||
"elm/html": "1.0.0",
|
||||
"elm/http": "2.0.0",
|
||||
"elm/json": "1.1.3",
|
||||
"elm/random": "1.0.0",
|
||||
"elm/svg": "1.0.1",
|
||||
"elm/time": "1.0.0",
|
||||
"elm/url": "1.0.0",
|
||||
"elm-community/dict-extra": "2.4.0",
|
||||
"elm-community/random-extra": "3.0.0",
|
||||
"fapian/elm-html-aria": "1.4.0",
|
||||
"lattyware/elm-fontawesome": "2.3.0",
|
||||
"myrho/elm-round": "1.0.4",
|
||||
"pablohirafuji/elm-qrcode": "3.1.1",
|
||||
"truqu/elm-base64": "2.0.4"
|
||||
},
|
||||
"indirect": {
|
||||
"elm/bytes": "1.0.8",
|
||||
"elm/file": "1.0.5",
|
||||
"elm/regex": "1.0.0",
|
||||
"elm/virtual-dom": "1.0.2",
|
||||
"elm-community/list-extra": "8.1.0"
|
||||
}
|
||||
},
|
||||
"test-dependencies": {
|
||||
"direct": {},
|
||||
"indirect": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
user nginx;
|
||||
worker_processes 1;
|
||||
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
resolver 127.0.0.11;
|
||||
|
||||
types {
|
||||
application/manifest+json webmanifest;
|
||||
}
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
|
||||
keepalive_timeout 65;
|
||||
|
||||
gzip on;
|
||||
|
||||
upstream server {
|
||||
server server:8081;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8080 default_server;
|
||||
listen [::]:8080 default_server;
|
||||
server_name _;
|
||||
|
||||
location / {
|
||||
root "/usr/share/nginx/html";
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://server;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "massivedecks",
|
||||
"version": "2.0.0",
|
||||
"description": "A web party game.",
|
||||
"main": "src/ts/index.ts",
|
||||
"author": "Gareth Latty <gareth@lattyware.co.uk>",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^5.9.0",
|
||||
"@webcomponents/webcomponentsjs": "^2.2.10",
|
||||
"sass-inline-svg": "^1.2.0",
|
||||
"weightless": "0.0.34"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chrome": "0.0.86",
|
||||
"@types/chromecast-caf-receiver": "^3.0.8",
|
||||
"@types/chromecast-caf-sender": "^1.0.1",
|
||||
"app-manifest-loader": "^2.3.1",
|
||||
"chromecast-device-emulator": "^1.2.7",
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"css-hot-loader": "^1.4.4",
|
||||
"css-loader": "^3.0.0",
|
||||
"cssnano": "^4.1.10",
|
||||
"elm": "^0.19.0-no-deps",
|
||||
"elm-hot-webpack-loader": "^1.0.2",
|
||||
"elm-webpack-loader": "^5.0.0",
|
||||
"extract-loader": "^3.1.0",
|
||||
"file-loader": "^4.0.0",
|
||||
"html-loader": "^0.5.5",
|
||||
"node-sass": "^4.12.0",
|
||||
"postcss-import": "^12.0.1",
|
||||
"postcss-loader": "^3.0.0",
|
||||
"postcss-preset-env": "^6.6.0",
|
||||
"prettier": "1.18.2",
|
||||
"prettier-plugin-elm": "^0.4.2",
|
||||
"sass-loader": "^7.1.0",
|
||||
"style-loader": "^0.23.1",
|
||||
"terser-webpack-plugin": "^1.3.0",
|
||||
"ts-loader": "^6.0.4",
|
||||
"typescript": "^3.5.2",
|
||||
"uglifyjs-webpack-plugin": "^2.1.3",
|
||||
"webpack": "^4.35.0",
|
||||
"webpack-cli": "^3.3.4",
|
||||
"webpack-dev-server": "^3.7.2"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 2 version",
|
||||
"not dead",
|
||||
"Firefox ESR",
|
||||
"> 0.2%"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npx webpack --mode=production",
|
||||
"watch": "npx webpack --mode=development --watch",
|
||||
"dev": "npx webpack-dev-server --mode=development",
|
||||
"errors": "npx webpack --mode=development --display-error-details"
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
module MassiveDecks exposing (main)
|
||||
|
||||
import Navigation
|
||||
import MassiveDecks.Models exposing (Init, Path, pathFromLocation)
|
||||
import MassiveDecks.Scenes.Start as Start
|
||||
import MassiveDecks.Scenes.Start.Messages as Start
|
||||
import MassiveDecks.Scenes.Start.Models as Start
|
||||
|
||||
|
||||
{-| The main application loop setup.
|
||||
-}
|
||||
main : Program Init Start.Model Start.Message
|
||||
main =
|
||||
Navigation.programWithFlags
|
||||
locationToMessage
|
||||
{ init = Start.init
|
||||
, update = Start.update
|
||||
, subscriptions = Start.subscriptions
|
||||
, view = Start.view
|
||||
}
|
||||
|
||||
|
||||
locationToMessage : Navigation.Location -> Start.Message
|
||||
locationToMessage location =
|
||||
pathFromLocation location |> Start.PathChange
|
||||
@@ -1,299 +0,0 @@
|
||||
module MassiveDecks.API exposing (..)
|
||||
|
||||
import Json.Decode as Decode
|
||||
import Json.Encode as Encode
|
||||
import MassiveDecks.API.Request exposing (..)
|
||||
import MassiveDecks.Scenes.Playing.HouseRule.Id as HouseRule
|
||||
import MassiveDecks.Models.Game as Game
|
||||
import MassiveDecks.Models.Game.Round as Round
|
||||
import MassiveDecks.Models.Card as Card
|
||||
import MassiveDecks.Models.Player as Player exposing (Player)
|
||||
import MassiveDecks.Models.JSON.Decode exposing (..)
|
||||
import MassiveDecks.Models.JSON.Encode exposing (..)
|
||||
|
||||
|
||||
{-| Makes a request to create a new game lobby to the server. On success, returns that lobby.
|
||||
-}
|
||||
createLobby : String -> Request Never Game.GameCodeAndSecret
|
||||
createLobby name =
|
||||
request "POST" "/api/lobbies" (Just (encodeName name)) [] gameCodeAndSecretDecoder
|
||||
|
||||
|
||||
{-| Errors specific to new player requests.
|
||||
* `NameInUse` - The request was to create a new player with a name already being uses in that lobby.
|
||||
* `LobbyNotFound` - The given lobby does not exist.
|
||||
-}
|
||||
type NewPlayerError
|
||||
= NameInUse
|
||||
| PasswordWrong
|
||||
| NewPlayerLobbyNotFound
|
||||
|
||||
|
||||
{-| Makes a request to add a new player to the given lobby. On success, returns a `Secret` for that player.
|
||||
-}
|
||||
newPlayer : String -> String -> String -> Request NewPlayerError Player.Secret
|
||||
newPlayer gameCode name password =
|
||||
request
|
||||
"POST"
|
||||
("/api/lobbies/" ++ gameCode ++ "/players")
|
||||
(Just (encodeNameAndPassword name password))
|
||||
[ ( ( 400, "name-in-use" ), Decode.succeed NameInUse )
|
||||
, ( ( 403, "password-wrong" ), Decode.succeed PasswordWrong )
|
||||
, ( ( 404, "lobby-not-found" ), Decode.succeed NewPlayerLobbyNotFound )
|
||||
]
|
||||
playerSecretDecoder
|
||||
|
||||
|
||||
{-| Errors specific to getting the lobby and hand.
|
||||
* `LobbyNotFound` - The given lobby does not exist.
|
||||
-}
|
||||
type GetLobbyAndHandError
|
||||
= LobbyNotFound
|
||||
| SecretWrongOrNotAPlayer
|
||||
|
||||
|
||||
{-| Get the lobby and the hand for the player with the given secret (using it to authenticate).
|
||||
-}
|
||||
getLobbyAndHand : String -> Player.Secret -> Request GetLobbyAndHandError Game.LobbyAndHand
|
||||
getLobbyAndHand =
|
||||
commandRequest
|
||||
"getLobbyAndHand"
|
||||
[]
|
||||
[ ( ( 404, "lobby-not-found" ), Decode.succeed LobbyNotFound )
|
||||
, ( ( 403, "secret-wrong-or-not-a-player" ), Decode.succeed SecretWrongOrNotAPlayer )
|
||||
]
|
||||
lobbyAndHandDecoder
|
||||
|
||||
|
||||
{-| Get the hand of the player with the given secret (using it to authenticate).
|
||||
-}
|
||||
getHand : String -> Player.Secret -> Request Never Card.Hand
|
||||
getHand gameCode secret =
|
||||
request
|
||||
"POST"
|
||||
("/api/lobbies/" ++ gameCode ++ "/players/" ++ (toString secret.id))
|
||||
(Just (encodePlayerSecret secret))
|
||||
[]
|
||||
handDecoder
|
||||
|
||||
|
||||
{-| Get the history of the given game.
|
||||
-}
|
||||
getHistory : String -> Request Never (List Round.FinishedRound)
|
||||
getHistory gameCode =
|
||||
request
|
||||
"GET"
|
||||
("/api/lobbies/" ++ gameCode ++ "/history")
|
||||
Nothing
|
||||
[]
|
||||
(Decode.list finishedRoundDecoder)
|
||||
|
||||
|
||||
{-| Errors specific to add deck requests.
|
||||
* `CardcastTimeout` - The server timed out trying to retrieve the deck from Cardcast.
|
||||
* `DeckNotFound` - The given play code does not resolve to a Cardcast deck.
|
||||
-}
|
||||
type AddDeckError
|
||||
= CardcastTimeout
|
||||
| DeckNotFound
|
||||
|
||||
|
||||
{-| Makes a request to add the deck for the given play code to the game configuration, using the given secret to
|
||||
authenticate.
|
||||
-}
|
||||
addDeck : String -> Player.Secret -> String -> Request AddDeckError ()
|
||||
addDeck gameCode secret playCode =
|
||||
commandRequest
|
||||
"addDeck"
|
||||
[ ( "playCode", Encode.string playCode ) ]
|
||||
[ ( ( 502, "cardcast-timeout" ), Decode.succeed CardcastTimeout )
|
||||
, ( ( 400, "deck-not-found" ), Decode.succeed DeckNotFound )
|
||||
]
|
||||
(Decode.succeed ())
|
||||
gameCode
|
||||
secret
|
||||
|
||||
|
||||
{-| Makes a request to the server to add a new AI player to the game.
|
||||
-}
|
||||
newAi : String -> Player.Secret -> Request Never ()
|
||||
newAi gameCode secret =
|
||||
request
|
||||
"POST"
|
||||
("/api/lobbies/" ++ gameCode ++ "/players/newAi")
|
||||
(Just (encodePlayerSecret secret))
|
||||
[]
|
||||
(Decode.succeed ())
|
||||
|
||||
|
||||
{-| Errors specific to starting a new game.
|
||||
* `NotEnoughPlayers` - There are not enough players in the lobby to start the game. The required number is given.
|
||||
* `GameInProgress` - There is already a game in progress.
|
||||
-}
|
||||
type NewGameError
|
||||
= NotEnoughPlayers Int
|
||||
| GameInProgress
|
||||
|
||||
|
||||
{-| Makes a request to the server to start a new game in the given lobby, using the given secret to authenticate.
|
||||
-}
|
||||
newGame : String -> Player.Secret -> Request NewGameError Card.Hand
|
||||
newGame gameCode secret =
|
||||
commandRequest
|
||||
"newGame"
|
||||
[]
|
||||
[ ( ( 400, "game-in-progress" ), Decode.succeed GameInProgress )
|
||||
, ( ( 400, "not-enough-players" ), Decode.map NotEnoughPlayers (Decode.field "required" Decode.int) )
|
||||
]
|
||||
handDecoder
|
||||
gameCode
|
||||
secret
|
||||
|
||||
|
||||
{-| Errors specific to choosing a winner for the round.
|
||||
* `NotCzar` - The player is not the card czar.
|
||||
-}
|
||||
type ChooseError
|
||||
= NotCzar
|
||||
|
||||
|
||||
{-| Make a request to choose the given (by index) winning response for round for the given lobby, using the given secret
|
||||
to authenticate.
|
||||
-}
|
||||
choose : String -> Player.Secret -> Int -> Request ChooseError ()
|
||||
choose gameCode secret winner =
|
||||
commandRequest
|
||||
"choose"
|
||||
[ ( "winner", Encode.int winner ) ]
|
||||
[ ( ( 400, "not-czar" ), Decode.succeed NotCzar ) ]
|
||||
(Decode.succeed ())
|
||||
gameCode
|
||||
secret
|
||||
|
||||
|
||||
{-| Errors specific to playing responses into the round.
|
||||
* `NotInRound` - The player is not in the round, and therefore can't play (i.e.: They are card czar, joined mid-round
|
||||
* or were skipped.)
|
||||
* `AlreadyPlayed` - The player has already played into the round.
|
||||
* `AlreadyJudging` - The round is in the judging phase, no more cards can be played.
|
||||
* `WrongNumberOfCards` - The wrong number of cards were played, with the number got, and the number expected.
|
||||
-}
|
||||
type PlayError
|
||||
= NotInRound
|
||||
| AlreadyPlayed
|
||||
| AlreadyJudging
|
||||
| WrongNumberOfCards Int Int
|
||||
|
||||
|
||||
{-| Make a request to play the given (by index) cards from the player's hand into the round for the given lobby, using
|
||||
the given secret to authenticate.
|
||||
-}
|
||||
play : String -> Player.Secret -> List String -> Request PlayError Card.Hand
|
||||
play gameCode secret ids =
|
||||
commandRequest
|
||||
"play"
|
||||
[ ( "ids", Encode.list (List.map Encode.string ids) ) ]
|
||||
[ ( ( 400, "not-in-round" ), Decode.succeed NotInRound )
|
||||
, ( ( 400, "already-played" ), Decode.succeed AlreadyPlayed )
|
||||
, ( ( 400, "already-judging" ), Decode.succeed AlreadyJudging )
|
||||
, ( ( 400, "wrong-number-of-cards-played" ), Decode.map2 WrongNumberOfCards (Decode.field "got" Decode.int) (Decode.field "expected" Decode.int) )
|
||||
]
|
||||
handDecoder
|
||||
gameCode
|
||||
secret
|
||||
|
||||
|
||||
{-| Errors specific to skipping a player in the lobby.
|
||||
* `NotEnoughPlayers` - The number of active players would drop below the minimum if the given players were
|
||||
* skipped.
|
||||
* `PlayersNotSkippable` - One of the given players was not in a state where they could be skipped (i.e.: not
|
||||
* disconnected or timed out).
|
||||
-}
|
||||
type SkipError
|
||||
= NotEnoughPlayersToSkip Int
|
||||
| PlayersNotSkippable
|
||||
|
||||
|
||||
{-| Make a request to skip the given players in the given lobby using the given secret to authenticate.
|
||||
-}
|
||||
skip : String -> Player.Secret -> List Player.Id -> Request SkipError ()
|
||||
skip gameCode secret players =
|
||||
commandRequest
|
||||
"skip"
|
||||
[ ( "players", Encode.list (List.map Encode.int players) ) ]
|
||||
[ ( ( 400, "not-enough-players" ), Decode.map NotEnoughPlayersToSkip (Decode.field "required" Decode.int) )
|
||||
, ( ( 400, "players-must-be-skippable" ), Decode.succeed PlayersNotSkippable )
|
||||
]
|
||||
(Decode.succeed ())
|
||||
gameCode
|
||||
secret
|
||||
|
||||
|
||||
{-| Make a request to stop being skipped.
|
||||
-}
|
||||
back : String -> Player.Secret -> Request Never ()
|
||||
back =
|
||||
commandRequest "back" [] [] (Decode.succeed ())
|
||||
|
||||
|
||||
{-| Make a request to leave the game.
|
||||
-}
|
||||
leave : String -> Player.Secret -> Request Never ()
|
||||
leave gameCode secret =
|
||||
request
|
||||
"POST"
|
||||
("/api/lobbies/" ++ gameCode ++ "/players/" ++ (toString secret.id) ++ "/leave")
|
||||
(Just (encodePlayerSecret secret))
|
||||
[]
|
||||
(Decode.succeed ())
|
||||
|
||||
|
||||
{-| Errors specific to redrawing.
|
||||
* `NotEnoughPoints` - The player does not have enough points to redraw.
|
||||
-}
|
||||
type RedrawError
|
||||
= NotEnoughPoints
|
||||
|
||||
|
||||
{-| Make a request to redraw the players hand, losing a point.
|
||||
-}
|
||||
redraw : String -> Player.Secret -> Request RedrawError Card.Hand
|
||||
redraw =
|
||||
commandRequest
|
||||
"redraw"
|
||||
[]
|
||||
[ ( ( 400, "not-enough-points" ), Decode.succeed NotEnoughPoints )
|
||||
]
|
||||
handDecoder
|
||||
|
||||
|
||||
{-| Make a request to enable a house rule.
|
||||
-}
|
||||
enableRule : HouseRule.Id -> String -> Player.Secret -> Request Never ()
|
||||
enableRule rule gameCode secret =
|
||||
commandRequest "enableRule" [ ( "rule", rule |> HouseRule.toString |> Encode.string ) ] [] (Decode.succeed ()) gameCode secret
|
||||
|
||||
|
||||
{-| Make a request to disable a house rule.
|
||||
-}
|
||||
disableRule : HouseRule.Id -> String -> Player.Secret -> Request Never ()
|
||||
disableRule rule gameCode secret =
|
||||
commandRequest "disableRule" [ ( "rule", rule |> HouseRule.toString |> Encode.string ) ] [] (Decode.succeed ()) gameCode secret
|
||||
|
||||
|
||||
{-| Make a request to set the lobby password
|
||||
-}
|
||||
setPassword : String -> Player.Secret -> String -> Request Never ()
|
||||
setPassword gameCode secret password =
|
||||
commandRequest "setPassword" [ ( "password", password |> Encode.string ) ] [] (Decode.succeed ()) gameCode secret
|
||||
|
||||
|
||||
|
||||
{- Private -}
|
||||
|
||||
|
||||
{-| Construct a request for a command.
|
||||
-}
|
||||
commandRequest : String -> List ( String, Encode.Value ) -> List (KnownError a) -> Decode.Decoder b -> String -> Player.Secret -> Request a b
|
||||
commandRequest name args errors decoder gameCode secret =
|
||||
request "POST" ("/api/lobbies/" ++ gameCode) (Just (encodeCommand name secret args)) errors decoder
|
||||
@@ -1,202 +0,0 @@
|
||||
module MassiveDecks.API.Request exposing (send, send_, request, Request, KnownError, Error(..))
|
||||
|
||||
import Json.Decode as Json
|
||||
import Dict exposing (Dict)
|
||||
import Http
|
||||
import MassiveDecks.Components.Errors as Errors
|
||||
import MassiveDecks.Util as Util
|
||||
|
||||
|
||||
{-| Send a request to the server.
|
||||
|
||||
request - The request to the server. See MassiveDecks.API.
|
||||
onSpecificError - How to handle any errors specific to the request.
|
||||
onGeneralError - How to handle any general errors.
|
||||
onSuccess - How to handle success.
|
||||
|
||||
If the request has no specific errors, use `send_` instead.
|
||||
-}
|
||||
send : Request specificError result -> (specificError -> message) -> (Errors.Message -> message) -> (result -> message) -> Cmd message
|
||||
send request onSpecificError onGeneralError onSuccess =
|
||||
let
|
||||
req =
|
||||
Http.request
|
||||
{ method = request.method
|
||||
, headers = []
|
||||
, url = request.url
|
||||
, body =
|
||||
case request.body of
|
||||
Just json ->
|
||||
Http.jsonBody json
|
||||
|
||||
Nothing ->
|
||||
Http.emptyBody
|
||||
, expect = Http.expectStringResponse (handleResponse request.resultDecoder request.errors)
|
||||
, timeout = Nothing
|
||||
, withCredentials = False
|
||||
}
|
||||
in
|
||||
Http.send
|
||||
(\result ->
|
||||
case result of
|
||||
Result.Ok errorOrResult ->
|
||||
case errorOrResult of
|
||||
Error error ->
|
||||
handleErrors onSpecificError onGeneralError error
|
||||
|
||||
Result result ->
|
||||
onSuccess result
|
||||
|
||||
Result.Err error ->
|
||||
(case error of
|
||||
Http.BadStatus response ->
|
||||
(Json.decodeString
|
||||
(Json.maybe errorKeyDecoder
|
||||
|> Json.andThen
|
||||
(\n ->
|
||||
case n of
|
||||
Just errorName ->
|
||||
errorDecoder response errorName request.errors
|
||||
|
||||
Nothing ->
|
||||
Json.succeed (General error)
|
||||
)
|
||||
)
|
||||
response.body
|
||||
)
|
||||
|> Result.withDefault (General error)
|
||||
|
||||
_ ->
|
||||
General error
|
||||
)
|
||||
|> handleErrors onSpecificError onGeneralError
|
||||
)
|
||||
req
|
||||
|
||||
|
||||
handleErrors : (specificError -> message) -> (Errors.Message -> message) -> Error specificError -> message
|
||||
handleErrors onSpecificError onGeneralError error =
|
||||
case error of
|
||||
Known specificError ->
|
||||
onSpecificError specificError
|
||||
|
||||
_ ->
|
||||
onGeneralError (genericErrorHandler error)
|
||||
|
||||
|
||||
handleResponse : Json.Decoder result -> KnownErrors specificError -> Http.Response String -> Result String (ErrorOrResult specificError result)
|
||||
handleResponse resultDecoder knownErrors response =
|
||||
Json.decodeString (resultOrErrorDecoder response resultDecoder knownErrors) response.body
|
||||
|
||||
|
||||
{-| Same as 'send', but for requests with no known errors.
|
||||
-}
|
||||
send_ : Request Never result -> (Errors.Message -> message) -> (result -> message) -> Cmd message
|
||||
send_ request onGeneralError onSuccess =
|
||||
send request Util.impossible onGeneralError onSuccess
|
||||
|
||||
|
||||
{-| A request to the API.
|
||||
-}
|
||||
type alias Request specificError result =
|
||||
{ method : String
|
||||
, url : String
|
||||
, body : Maybe Json.Value
|
||||
, errors : KnownErrors specificError
|
||||
, resultDecoder : Json.Decoder result
|
||||
}
|
||||
|
||||
|
||||
{-| A convinience method to make the errors dictionary from a list.
|
||||
-}
|
||||
request : String -> String -> Maybe Json.Value -> List (KnownError specificError) -> Json.Decoder result -> Request specificError result
|
||||
request verb url body errors resultDecoder =
|
||||
Request verb url body (Dict.fromList errors) resultDecoder
|
||||
|
||||
|
||||
{-| Specifies an error the client understands and can present to the user nicely.
|
||||
-}
|
||||
type alias KnownError specificError =
|
||||
( ( Int, String ), Json.Decoder specificError )
|
||||
|
||||
|
||||
{-| The dictionary of KnownErrors.
|
||||
-}
|
||||
type alias KnownErrors specificError =
|
||||
Dict ( Int, String ) (Json.Decoder specificError)
|
||||
|
||||
|
||||
{-| The top level of potential errors from an API request.
|
||||
-}
|
||||
type Error specificError
|
||||
= General Http.Error
|
||||
| Known specificError
|
||||
| Unknown (Http.Response String)
|
||||
|
||||
|
||||
type ErrorOrResult specificError result
|
||||
= Error (Error specificError)
|
||||
| Result result
|
||||
|
||||
|
||||
{-| Convert errors to generic error messages for display in the errors component. Generally you want to use this as a
|
||||
fallback after handling all known errors.
|
||||
-}
|
||||
genericErrorHandler : Error a -> Errors.Message
|
||||
genericErrorHandler error =
|
||||
case error of
|
||||
Known specificError ->
|
||||
Errors.New ("An error was not correctly handled: " ++ (toString specificError)) True
|
||||
|
||||
Unknown response ->
|
||||
Errors.New ("An error was not not recognised (status " ++ (toString response.status.code) ++ "): " ++ response.body) True
|
||||
|
||||
General (Http.Timeout) ->
|
||||
Errors.New "Timed out trying to connect to the server." False
|
||||
|
||||
General (Http.NetworkError) ->
|
||||
Errors.New "There was a network error trying to connect to the server." False
|
||||
|
||||
General (Http.BadUrl url) ->
|
||||
Errors.New ("The URL '" ++ url ++ "' was invalid.") True
|
||||
|
||||
General (Http.BadStatus response) ->
|
||||
Errors.New ("Recieved an unexpected response (" ++ (toString response.status.code) ++ ") from the server: " ++ response.status.message) True
|
||||
|
||||
General (Http.BadPayload explanation response) ->
|
||||
Errors.New ("The response recieved from the server wasn't what we expected: " ++ explanation) True
|
||||
|
||||
|
||||
{-| Decode the error key from
|
||||
-}
|
||||
errorKeyDecoder : Json.Decoder String
|
||||
errorKeyDecoder =
|
||||
Json.at [ "error" ] Json.string
|
||||
|
||||
|
||||
errorDecoder : Http.Response String -> String -> KnownErrors specificError -> Json.Decoder (Error specificError)
|
||||
errorDecoder response errorName knownErrors =
|
||||
let
|
||||
decoder =
|
||||
Dict.get ( response.status.code, errorName ) knownErrors
|
||||
in
|
||||
case decoder of
|
||||
Just decoder ->
|
||||
Json.map Known decoder
|
||||
|
||||
Nothing ->
|
||||
Json.succeed (Unknown response)
|
||||
|
||||
|
||||
resultOrErrorDecoder : Http.Response String -> Json.Decoder result -> KnownErrors specificError -> Json.Decoder (ErrorOrResult specificError result)
|
||||
resultOrErrorDecoder response resultDecoder knownErrors =
|
||||
Json.maybe errorKeyDecoder
|
||||
|> Json.andThen
|
||||
(\error ->
|
||||
case error of
|
||||
Just errorName ->
|
||||
Json.map (Error) (errorDecoder response errorName knownErrors)
|
||||
|
||||
Nothing ->
|
||||
Json.map Result resultDecoder
|
||||
)
|
||||
@@ -1,83 +0,0 @@
|
||||
module MassiveDecks.Components.About exposing (show)
|
||||
|
||||
import String
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import MassiveDecks.Components.Overlay as Overlay exposing (Overlay)
|
||||
|
||||
|
||||
show : String -> Overlay.Message a
|
||||
show version =
|
||||
Overlay.Show (Overlay icon title (contents version))
|
||||
|
||||
|
||||
icon : String
|
||||
icon =
|
||||
"info-circle"
|
||||
|
||||
|
||||
title : String
|
||||
title =
|
||||
"About"
|
||||
|
||||
|
||||
contents : String -> List (Html a)
|
||||
contents version =
|
||||
([ p []
|
||||
[ text "Massive Decks is a web game based on the excellent "
|
||||
, a [ href "https://cardsagainsthumanity.com/", target "_blank", rel "noopener" ] [ text "Cards against Humanity" ]
|
||||
, text " - a party game where you play white cards to try and produce the most amusing outcome when "
|
||||
, text "combined with the given black card."
|
||||
]
|
||||
, p []
|
||||
[ text "Massive Decks is also inspired by: "
|
||||
, ul []
|
||||
[ li []
|
||||
[ a [ href "https://www.cardcastgame.com/", target "_blank", rel "noopener" ] [ text "Cardcast" ]
|
||||
, text " - an app that allows you to play on a ChromeCast."
|
||||
]
|
||||
, li []
|
||||
[ a [ href "http://pretendyoure.xyz/zy/", target "_blank", rel "noopener" ] [ text "Pretend You're Xyzzy" ]
|
||||
, text " - a web game where you can jump in with people you don't know."
|
||||
]
|
||||
]
|
||||
]
|
||||
, p []
|
||||
[ text "This is an open source game developed in "
|
||||
, a [ href "http://elm-lang.org/", target "_blank", rel "noopener" ] [ text "Elm" ]
|
||||
, text " for the client and "
|
||||
, a [ href "http://www.scala-lang.org/", target "_blank", rel "noopener" ] [ text "Scala" ]
|
||||
, text " for the server."
|
||||
]
|
||||
, p []
|
||||
[ text "We also use: "
|
||||
, ul []
|
||||
[ li []
|
||||
[ a [ href "https://www.cardcastgame.com/", target "_blank", rel "noopener" ] [ text "Cardcast" ]
|
||||
, text "'s APIs for getting decks of cards (you can go there to make your own!)."
|
||||
]
|
||||
, li []
|
||||
[ text "The "
|
||||
, a [ href "https://www.playframework.com/", target "_blank", rel "noopener" ] [ text "Play framework" ]
|
||||
]
|
||||
, li [] [ a [ href "http://lesscss.org/", target "_blank", rel "noopener" ] [ text "Less" ] ]
|
||||
, li [] [ a [ href "https://fortawesome.github.io/Font-Awesome/", target "_blank", rel "noopener" ] [ text "Font Awesome" ] ]
|
||||
, li [] [ a [ href "https://www.muicss.com", target "_blank", rel "noopener" ] [ text "MUI" ] ]
|
||||
]
|
||||
]
|
||||
, p []
|
||||
[ text "Bug reports and contributions are welcome on the "
|
||||
, a [ href "https://github.com/Lattyware/massivedecks", target "_blank", rel "noopener" ] [ text "GitHub repository" ]
|
||||
, text ", where you can find the complete source to the game, under the AGPLv3 license. The game concept "
|
||||
, text "'Cards against Humanity' is used under a "
|
||||
, a [ href "https://creativecommons.org/licenses/by-nc-sa/2.0/", target "_blank", rel "noopener" ] [ text "Creative Commons BY-NC-SA 2.0 license" ]
|
||||
, text " granted by "
|
||||
, a [ href "https://cardsagainsthumanity.com/", target "_blank", rel "noopener" ] [ text "Cards against Humanity" ]
|
||||
]
|
||||
]
|
||||
)
|
||||
++ (if String.isEmpty version then
|
||||
[]
|
||||
else
|
||||
[ p [] [ text ("This server is running version " ++ version ++ ".") ] ]
|
||||
)
|
||||
@@ -1,117 +0,0 @@
|
||||
port module MassiveDecks.Components.BrowserNotifications exposing (Model, Message, ConsumerMessage(..), Permission(..), init, update, subscriptions, notify, enable, disable)
|
||||
|
||||
import Maybe
|
||||
import MassiveDecks.Util as Util
|
||||
|
||||
|
||||
init : Bool -> Bool -> Model
|
||||
init supported enabled =
|
||||
{ supported = supported
|
||||
, enabled = enabled
|
||||
, permission = Nothing
|
||||
}
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ supported : Bool
|
||||
, enabled : Bool
|
||||
, permission : Maybe Permission
|
||||
}
|
||||
|
||||
|
||||
type alias Notification =
|
||||
{ title : String
|
||||
, icon : Maybe String
|
||||
}
|
||||
|
||||
|
||||
type Permission
|
||||
= Granted
|
||||
| Denied
|
||||
| Default
|
||||
|
||||
|
||||
update : Message -> Model -> ( Model, Cmd Message, Cmd ConsumerMessage )
|
||||
update message model =
|
||||
case message of
|
||||
PermissionGiven permission ->
|
||||
( { model | permission = Just permission }, Cmd.none, Util.cmd (PermissionChanged permission) )
|
||||
|
||||
SendNotification notification ->
|
||||
if model.supported && model.enabled then
|
||||
( model, notifications notification, Cmd.none )
|
||||
else
|
||||
( model, Cmd.none, Cmd.none )
|
||||
|
||||
EnableNotifications ->
|
||||
( { model | enabled = True }, requestPermission (), Cmd.none )
|
||||
|
||||
DisableNotifications ->
|
||||
( { model | enabled = False }, Cmd.none, Cmd.none )
|
||||
|
||||
|
||||
type ConsumerMessage
|
||||
= PermissionChanged Permission
|
||||
|
||||
|
||||
type Message
|
||||
= PermissionGiven Permission
|
||||
| SendNotification Notification
|
||||
| EnableNotifications
|
||||
| DisableNotifications
|
||||
|
||||
|
||||
subscriptions : Model -> Sub Message
|
||||
subscriptions model =
|
||||
if model.supported && model.enabled && model.permission == Nothing then
|
||||
permissions permission
|
||||
else
|
||||
Sub.none
|
||||
|
||||
|
||||
port permissions : (String -> msg) -> Sub msg
|
||||
|
||||
|
||||
permission : String -> Message
|
||||
permission name =
|
||||
let
|
||||
permission =
|
||||
case name of
|
||||
"granted" ->
|
||||
Granted
|
||||
|
||||
"denied" ->
|
||||
Denied
|
||||
|
||||
"default" ->
|
||||
Default
|
||||
|
||||
_ ->
|
||||
let
|
||||
_ =
|
||||
Debug.log "Unexpected permission for browser notifications, assuming denied" name
|
||||
in
|
||||
Denied
|
||||
in
|
||||
PermissionGiven permission
|
||||
|
||||
|
||||
notify : Notification -> Message
|
||||
notify notification =
|
||||
SendNotification notification
|
||||
|
||||
|
||||
port notifications : Notification -> Cmd msg
|
||||
|
||||
|
||||
enable : Message
|
||||
enable =
|
||||
EnableNotifications
|
||||
|
||||
|
||||
disable : Message
|
||||
disable =
|
||||
DisableNotifications
|
||||
|
||||
|
||||
port requestPermission : () -> Cmd msg
|
||||
@@ -1,121 +0,0 @@
|
||||
module MassiveDecks.Components.Errors exposing (Message(..), Model, ApplicationInfo, view, update, init, reportUrl)
|
||||
|
||||
import String
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import MassiveDecks.Components.Icon as Icon
|
||||
import MassiveDecks.Util as Util
|
||||
|
||||
|
||||
type Message
|
||||
= New String Bool
|
||||
| Remove Int
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ currentId : Int
|
||||
, errors : List Error
|
||||
}
|
||||
|
||||
|
||||
type alias ApplicationInfo =
|
||||
{ url : String
|
||||
, version : String
|
||||
}
|
||||
|
||||
|
||||
{-| A generic error message to be displayed when something goes wrong. Should only be used where there isn't a good way
|
||||
to avoid the error altogether or display the error closer to it's source.
|
||||
-}
|
||||
type alias Error =
|
||||
{ id : Int
|
||||
, message : String
|
||||
, bugReport : Bool
|
||||
}
|
||||
|
||||
|
||||
init : Model
|
||||
init =
|
||||
{ currentId = 0
|
||||
, errors = []
|
||||
}
|
||||
|
||||
|
||||
view : ApplicationInfo -> Model -> Html Message
|
||||
view applicationInfo model =
|
||||
ol [ id "error-panel" ] (List.map (errorMessage applicationInfo) model.errors)
|
||||
|
||||
|
||||
update : Message -> Model -> ( Model, Cmd Message )
|
||||
update message model =
|
||||
case message of
|
||||
New message bugReport ->
|
||||
let
|
||||
new =
|
||||
{ id = model.currentId, message = message, bugReport = bugReport }
|
||||
in
|
||||
( { model
|
||||
| errors = model.errors ++ [ new ]
|
||||
, currentId = model.currentId + 1
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
Remove id ->
|
||||
( { model | errors = List.filter (\error -> error.id /= id) model.errors }, Cmd.none )
|
||||
|
||||
|
||||
reportText : String -> String
|
||||
reportText message =
|
||||
("I was [a short explanation of what you were doing] when I got the following error: \n\n" ++ message)
|
||||
|
||||
|
||||
reportUrl : ApplicationInfo -> String -> String
|
||||
reportUrl applicationInfo message =
|
||||
let
|
||||
version =
|
||||
if String.isEmpty applicationInfo.version then
|
||||
"Not Specified"
|
||||
else
|
||||
applicationInfo.version
|
||||
|
||||
full =
|
||||
message ++ "\n\nApplication Info:\n\tVersion: " ++ version ++ "\n\tURL: " ++ applicationInfo.url
|
||||
in
|
||||
"https://github.com/Lattyware/massivedecks/issues/new?body=" ++ full
|
||||
|
||||
|
||||
errorMessage : ApplicationInfo -> Error -> Html Message
|
||||
errorMessage applicationInfo error =
|
||||
let
|
||||
url =
|
||||
reportUrl applicationInfo (reportText error.message)
|
||||
|
||||
bugReportLink =
|
||||
if error.bugReport then
|
||||
Just (p [] [ a [ href url, target "_blank", rel "noopener" ] [ Icon.icon "bug", text " Report this as a bug." ] ])
|
||||
else
|
||||
Nothing
|
||||
in
|
||||
li
|
||||
[ class "error" ]
|
||||
[ div
|
||||
[]
|
||||
([ a
|
||||
[ class "link"
|
||||
, attribute "tabindex" "0"
|
||||
, attribute "role" "button"
|
||||
, onClick (Remove error.id)
|
||||
]
|
||||
[ Icon.icon "times" ]
|
||||
, h5 []
|
||||
[ Icon.icon "exclamation-triangle"
|
||||
, text " Error"
|
||||
]
|
||||
, div [ class "mui-divider" ] []
|
||||
, p [] [ text error.message ]
|
||||
]
|
||||
|> Util.andMaybe bugReportLink
|
||||
)
|
||||
]
|
||||
@@ -1,25 +0,0 @@
|
||||
module MassiveDecks.Components.Icon exposing (..)
|
||||
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
|
||||
|
||||
{-| A FointAwesome icon by name.
|
||||
-}
|
||||
icon : String -> Html a
|
||||
icon name =
|
||||
i [ class ("fa fa-" ++ name) ] []
|
||||
|
||||
|
||||
{-| A full width FointAwesome icon by name.
|
||||
-}
|
||||
fwIcon : String -> Html a
|
||||
fwIcon name =
|
||||
i [ class ("fa fa-fw fa-" ++ name) ] []
|
||||
|
||||
|
||||
{-| A loading spinner.
|
||||
-}
|
||||
spinner : Html a
|
||||
spinner =
|
||||
i [ class "fa fa-circle-o-notch fa-spin" ] []
|
||||
@@ -1,153 +0,0 @@
|
||||
module MassiveDecks.Components.Input exposing (Message, Model, Change(..), init, initWithExtra, subscriptions, view, update)
|
||||
|
||||
import Json.Decode as Json
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import MassiveDecks.Util as Util
|
||||
import MassiveDecks.Components.Icon as Icon
|
||||
|
||||
|
||||
{-| Messages for changes to the input.
|
||||
-}
|
||||
type alias Message id =
|
||||
( id, Change )
|
||||
|
||||
|
||||
{-| Changes to the input.
|
||||
-}
|
||||
type Change
|
||||
= Changed String
|
||||
| Error (Maybe String)
|
||||
| Submit
|
||||
| SetEnabled Bool
|
||||
| SetDefaultValue String
|
||||
| NoOp
|
||||
|
||||
|
||||
{-| The state of the input.
|
||||
-}
|
||||
type alias Model id msg =
|
||||
{ identity : id
|
||||
, class : String
|
||||
, label : List (Html msg)
|
||||
, placeholder : String
|
||||
, value : String
|
||||
, error : Maybe String
|
||||
, extra : String -> List (Html msg)
|
||||
, embedMethod : Message id -> msg
|
||||
, submit : Cmd msg
|
||||
, enabled : Bool
|
||||
}
|
||||
|
||||
|
||||
{-| Create the initial model.
|
||||
|
||||
The identity is unique to the input, used to differentiate between different inputs.
|
||||
The class is the class of the wrapping div.
|
||||
The label is any HTML that will be inserted into the lable after the info icon.
|
||||
The value is the initial value of the input. If the input update is run, this will always contain the value currently
|
||||
shown in the textbox - however, changing it will not change the value of the textbox.
|
||||
The placeholder is a value to show if no value is entered.
|
||||
The error is a message that will appear next to the input if set.
|
||||
The extra is a method to produce anything to be added to the input (e.g: a submit button). It will be given the
|
||||
current value.
|
||||
The embedMethod is how to wrap the input message for the surrounding message type.
|
||||
-}
|
||||
init : id -> String -> List (Html msg) -> String -> String -> Cmd msg -> (Message id -> msg) -> Model id msg
|
||||
init identity class label value placeholder submit embedMethod =
|
||||
initWithExtra identity class label value placeholder (\_ -> []) submit embedMethod
|
||||
|
||||
|
||||
{-| Create the initial model with some extra content. See init for most of how this works.
|
||||
|
||||
The extra is a method to produce anything to be added to the input (e.g: a submit button). It will be given the
|
||||
current value.
|
||||
-}
|
||||
initWithExtra : id -> String -> List (Html msg) -> String -> String -> (String -> List (Html msg)) -> Cmd msg -> (Message id -> msg) -> Model id msg
|
||||
initWithExtra identity class label value placeholder extra submit embedMethod =
|
||||
{ identity = identity
|
||||
, class = class
|
||||
, label = label
|
||||
, value = value
|
||||
, placeholder = placeholder
|
||||
, error = Nothing
|
||||
, extra = extra
|
||||
, embedMethod = embedMethod
|
||||
, submit = submit
|
||||
, enabled = True
|
||||
}
|
||||
|
||||
|
||||
{-| Subscriptions for the input.
|
||||
-}
|
||||
subscriptions : Model id msg -> Sub (Message id)
|
||||
subscriptions model =
|
||||
Sub.none
|
||||
|
||||
|
||||
{-| Render the input.
|
||||
-}
|
||||
view : Model id msg -> Html msg
|
||||
view model =
|
||||
div [ class model.class ]
|
||||
([ div [ class "mui-textfield" ]
|
||||
([ input
|
||||
[ type_ "text"
|
||||
, defaultValue model.value
|
||||
, placeholder model.placeholder
|
||||
, disabled (not model.enabled)
|
||||
, on "input" (Json.map (\value -> (model.embedMethod ( model.identity, Changed value ))) targetValue)
|
||||
, Util.onKeyDown "Enter" (model.embedMethod ( model.identity, Submit )) (model.embedMethod ( model.identity, NoOp ))
|
||||
]
|
||||
[]
|
||||
, label [] (List.append [ Icon.icon "info-circle", text " " ] model.label)
|
||||
]
|
||||
|> Util.andMaybe (error model.error)
|
||||
)
|
||||
]
|
||||
++ model.extra model.value
|
||||
)
|
||||
|
||||
|
||||
{-| Render an error message for the input.
|
||||
-}
|
||||
error : Maybe String -> Maybe (Html msg)
|
||||
error message =
|
||||
Maybe.map (\error -> span [ class "input-error" ] [ Icon.icon "exclamation", text " ", text error ]) message
|
||||
|
||||
|
||||
{-| Handles messages and alters the model as appropriate.
|
||||
-}
|
||||
update : Message id -> Model id msg -> ( Model id msg, Cmd msg )
|
||||
update message model =
|
||||
let
|
||||
( identity, change ) =
|
||||
message
|
||||
in
|
||||
if (identity == model.identity) then
|
||||
case change of
|
||||
Changed value ->
|
||||
( { model
|
||||
| value = value
|
||||
, error = Nothing
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
Error error ->
|
||||
( { model | error = error }, Cmd.none )
|
||||
|
||||
Submit ->
|
||||
( { model | error = Nothing }, model.submit )
|
||||
|
||||
SetEnabled enabled ->
|
||||
( { model | enabled = enabled }, Cmd.none )
|
||||
|
||||
SetDefaultValue value ->
|
||||
( { model | value = value }, Cmd.none )
|
||||
|
||||
NoOp ->
|
||||
( model, Cmd.none )
|
||||
else
|
||||
( { model | error = Nothing }, Cmd.none )
|
||||
@@ -1,91 +0,0 @@
|
||||
port module MassiveDecks.Components.Overlay exposing (Overlay, Model, Message(..), init, view, update, map)
|
||||
|
||||
import Json.Decode as Json
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import MassiveDecks.Components.Icon as Icon
|
||||
import MassiveDecks.Util as Util
|
||||
|
||||
|
||||
type alias Model a =
|
||||
{ overlay : Maybe (Overlay a)
|
||||
, wrap : Message a -> a
|
||||
}
|
||||
|
||||
|
||||
type alias Overlay a =
|
||||
{ icon : String
|
||||
, title : String
|
||||
, contents : List (Html a)
|
||||
}
|
||||
|
||||
|
||||
type Message a
|
||||
= Show (Overlay a)
|
||||
| Hide
|
||||
| NoOp
|
||||
|
||||
|
||||
init : (Message a -> a) -> Model a
|
||||
init wrap =
|
||||
{ overlay = Nothing
|
||||
, wrap = wrap
|
||||
}
|
||||
|
||||
|
||||
map : (a -> b) -> Message a -> Message b
|
||||
map mapper message =
|
||||
case message of
|
||||
Show overlay ->
|
||||
Show (Overlay overlay.icon overlay.title (List.map (Html.map mapper) overlay.contents))
|
||||
|
||||
Hide ->
|
||||
Hide
|
||||
|
||||
NoOp ->
|
||||
NoOp
|
||||
|
||||
|
||||
update : Message a -> Model a -> Model a
|
||||
update message model =
|
||||
case message of
|
||||
Show overlay ->
|
||||
{ model | overlay = Just overlay }
|
||||
|
||||
Hide ->
|
||||
{ model | overlay = Nothing }
|
||||
|
||||
NoOp ->
|
||||
model
|
||||
|
||||
|
||||
view : Model a -> List (Html a)
|
||||
view model =
|
||||
case model.overlay of
|
||||
Just overlay ->
|
||||
[ div
|
||||
[ id "mui-overlay"
|
||||
, Util.onClickIfId "mui-overlay" (model.wrap Hide) (model.wrap NoOp)
|
||||
, Util.onKeyDown "Escape" (model.wrap Hide) (model.wrap NoOp)
|
||||
, tabindex 0
|
||||
]
|
||||
[ div [ class "overlay mui-panel" ]
|
||||
([ h1 [] [ Icon.icon overlay.icon, text " ", text overlay.title ] ]
|
||||
++ overlay.contents
|
||||
++ [ p [ class "close-link" ]
|
||||
[ a
|
||||
[ class "link"
|
||||
, attribute "tabindex" "0"
|
||||
, attribute "role" "button"
|
||||
, onClick (model.wrap Hide)
|
||||
]
|
||||
[ Icon.icon "times", text " Close" ]
|
||||
]
|
||||
]
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
Nothing ->
|
||||
[]
|
||||
@@ -1,17 +0,0 @@
|
||||
port module MassiveDecks.Components.QR exposing (view, encodeAndRender)
|
||||
|
||||
import Html exposing (Html)
|
||||
import Html.Attributes as Html
|
||||
|
||||
|
||||
port qr : { id : String, value : String } -> Cmd msg
|
||||
|
||||
|
||||
view : String -> Html msg
|
||||
view containerId =
|
||||
Html.div [ Html.id containerId ] []
|
||||
|
||||
|
||||
encodeAndRender : String -> String -> Cmd msg
|
||||
encodeAndRender containerId value =
|
||||
qr { id = containerId, value = value }
|
||||
@@ -1,40 +0,0 @@
|
||||
port module MassiveDecks.Components.Storage exposing (Model, Message(..), update, join, leave)
|
||||
|
||||
import MassiveDecks.Models.Game as Game
|
||||
|
||||
|
||||
type alias Model =
|
||||
List Game.GameCodeAndSecret
|
||||
|
||||
|
||||
type Message
|
||||
= Store
|
||||
| Clear
|
||||
|
||||
|
||||
port store : Model -> Cmd msg
|
||||
|
||||
|
||||
update : Message -> Model -> ( Model, Cmd Message )
|
||||
update message model =
|
||||
case message of
|
||||
Store ->
|
||||
( model, store model )
|
||||
|
||||
Clear ->
|
||||
( [], store [] )
|
||||
|
||||
|
||||
join : Game.GameCodeAndSecret -> Model -> Model
|
||||
join gameCodeAndSecret model =
|
||||
gameCodeAndSecret :: List.filter (different gameCodeAndSecret) model
|
||||
|
||||
|
||||
leave : Game.GameCodeAndSecret -> Model -> Model
|
||||
leave gameCodeAndSecret =
|
||||
List.filter (different gameCodeAndSecret)
|
||||
|
||||
|
||||
different : Game.GameCodeAndSecret -> Game.GameCodeAndSecret -> Bool
|
||||
different check existing =
|
||||
check.gameCode /= existing.gameCode
|
||||
@@ -1,41 +0,0 @@
|
||||
port module MassiveDecks.Components.TTS exposing (Model, Message(..), update, init)
|
||||
|
||||
import MassiveDecks.Util as Util
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ enabled : Bool
|
||||
}
|
||||
|
||||
|
||||
type Message
|
||||
= Say String
|
||||
| Enabled Bool
|
||||
|
||||
|
||||
init : Model
|
||||
init =
|
||||
{ enabled = False }
|
||||
|
||||
|
||||
update : Message -> Model -> ( Model, Cmd Message )
|
||||
update message model =
|
||||
case message of
|
||||
Say text ->
|
||||
( model
|
||||
, if model.enabled then
|
||||
say text
|
||||
else
|
||||
Cmd.none
|
||||
)
|
||||
|
||||
Enabled enabled ->
|
||||
( { model | enabled = enabled }
|
||||
, if enabled then
|
||||
Cmd.none
|
||||
else
|
||||
Say "" |> Util.cmd
|
||||
)
|
||||
|
||||
|
||||
port say : String -> Cmd msg
|
||||
@@ -1,71 +0,0 @@
|
||||
port module MassiveDecks.Components.Tabs exposing (Message(..), Model, Tab, init, update, view)
|
||||
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
|
||||
|
||||
{-| Messages for the tab system.
|
||||
-}
|
||||
type Message tabId
|
||||
= SetTab tabId
|
||||
|
||||
|
||||
{-| The model for the tabs.
|
||||
-}
|
||||
type alias Model tabId msg =
|
||||
{ tabs : List (Tab tabId msg)
|
||||
, current : tabId
|
||||
, tagger : Message tabId -> msg
|
||||
}
|
||||
|
||||
|
||||
{-| An individual tab.
|
||||
-}
|
||||
type alias Tab tabId msg =
|
||||
{ id : tabId
|
||||
, title : List (Html msg)
|
||||
}
|
||||
|
||||
|
||||
{-| Initialise the model.
|
||||
-}
|
||||
init : List (Tab tabId msg) -> tabId -> (Message tabId -> msg) -> Model tabId msg
|
||||
init =
|
||||
Model
|
||||
|
||||
|
||||
{-| Given a message, update the model to fit.
|
||||
-}
|
||||
update : Message tabId -> Model tabId msg -> Model tabId msg
|
||||
update message model =
|
||||
case message of
|
||||
SetTab tabId ->
|
||||
{ model | current = tabId }
|
||||
|
||||
|
||||
{-| Given a model, render it.
|
||||
|
||||
The first argument is a rendering function for the content of the tab. This allows the caller of the view to control the
|
||||
content easily.
|
||||
-}
|
||||
view : (tabId -> List (Html msg)) -> Model tabId msg -> List (Html msg)
|
||||
view renderer model =
|
||||
[ ul [ class "mui-tabs__bar mui-tabs__bar--justified" ] (List.map (viewTab model.tagger model.current) model.tabs)
|
||||
]
|
||||
++ (List.map (viewPane model.current renderer) model.tabs)
|
||||
|
||||
|
||||
viewTab : (Message tabId -> msg) -> tabId -> Tab tabId msg -> Html msg
|
||||
viewTab tagger current model =
|
||||
li [ classList [ ( "mui--is-active", current == model.id ) ] ]
|
||||
[ a
|
||||
[ onClick (tagger <| SetTab model.id)
|
||||
]
|
||||
model.title
|
||||
]
|
||||
|
||||
|
||||
viewPane : tabId -> (tabId -> List (Html msg)) -> Tab tabId msg -> Html msg
|
||||
viewPane current renderer model =
|
||||
div [ classList [ ( "mui-tabs__pane", True ), ( "mui--is-active", current == model.id ) ] ] (renderer model.id)
|
||||
@@ -1,9 +0,0 @@
|
||||
port module MassiveDecks.Components.Title exposing (set)
|
||||
|
||||
|
||||
port title : String -> Cmd msg
|
||||
|
||||
|
||||
set : String -> Cmd msg
|
||||
set =
|
||||
title
|
||||
@@ -1,28 +0,0 @@
|
||||
module MassiveDecks.Models exposing (..)
|
||||
|
||||
import Navigation
|
||||
import MassiveDecks.Models.Game exposing (GameCodeAndSecret)
|
||||
|
||||
|
||||
{-| Data required to create the initial application state.
|
||||
-}
|
||||
type alias Init =
|
||||
{ version : String
|
||||
, url : String
|
||||
, existingGames : List GameCodeAndSecret
|
||||
, seed : String
|
||||
, browserNotificationsSupported : Bool
|
||||
}
|
||||
|
||||
|
||||
{-| A path to a part of the application.
|
||||
-}
|
||||
type alias Path =
|
||||
{ gameCode : Maybe String
|
||||
}
|
||||
|
||||
|
||||
pathFromLocation : Navigation.Location -> Path
|
||||
pathFromLocation location =
|
||||
{ gameCode = Maybe.map Tuple.second (String.uncons location.hash)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
module MassiveDecks.Models.Card exposing (..)
|
||||
|
||||
import String
|
||||
import Dict exposing (Dict)
|
||||
import MassiveDecks.Models.Player as Player
|
||||
import MassiveDecks.Util as Util
|
||||
|
||||
|
||||
{-| A call (black card) is composed of a list of strings making up the text of the card, with a blank space ('slot') for
|
||||
a response implicitly existing inbetween each string.
|
||||
-}
|
||||
type alias Call =
|
||||
{ id : String
|
||||
, parts : List String
|
||||
}
|
||||
|
||||
|
||||
{-| A response (white card).
|
||||
-}
|
||||
type alias Response =
|
||||
{ id : String
|
||||
, text : String
|
||||
}
|
||||
|
||||
|
||||
{-| A hand of a player.
|
||||
-}
|
||||
type alias Hand =
|
||||
{ hand : List Response
|
||||
}
|
||||
|
||||
|
||||
{-| Cards that have been played into a round for a call.
|
||||
-}
|
||||
type alias PlayedCards =
|
||||
List Response
|
||||
|
||||
|
||||
{-| The number of slots on a given call.
|
||||
-}
|
||||
slots : Call -> Int
|
||||
slots call =
|
||||
(List.length call.parts) - 1
|
||||
|
||||
|
||||
{-| Produce a string of the given call with the given played cards injected into it.
|
||||
-}
|
||||
filled : Call -> PlayedCards -> String
|
||||
filled call playedCards =
|
||||
String.concat (Util.interleave (List.map .text playedCards) call.parts)
|
||||
|
||||
|
||||
{-| Join the player ids to the cards played into a round.
|
||||
-}
|
||||
playedCardsByPlayer : List Player.Id -> List PlayedCards -> Dict Player.Id PlayedCards
|
||||
playedCardsByPlayer players cards =
|
||||
List.map2 (,) players cards |> Dict.fromList
|
||||
|
||||
|
||||
{-| The cards played by the winner of the game.
|
||||
-}
|
||||
winningCards : List PlayedCards -> Player.PlayedByAndWinner -> Maybe PlayedCards
|
||||
winningCards cards playedByAndWinner =
|
||||
let
|
||||
cardsByPlayer =
|
||||
playedCardsByPlayer playedByAndWinner.playedBy cards
|
||||
|
||||
winner =
|
||||
playedByAndWinner.winner
|
||||
in
|
||||
Dict.get winner cardsByPlayer
|
||||
@@ -1,94 +0,0 @@
|
||||
module MassiveDecks.Models.Event exposing (Event(..), fromJson)
|
||||
|
||||
import Json.Decode as Json
|
||||
import MassiveDecks.Models.JSON.Decode exposing (..)
|
||||
import MassiveDecks.Models.Game as Game
|
||||
import MassiveDecks.Models.Game.Round as Round
|
||||
import MassiveDecks.Models.Player as Player exposing (Player)
|
||||
import MassiveDecks.Models.Card as Card
|
||||
|
||||
|
||||
{-| An event represents a change in the game state from the server. These are recieved by websocket.
|
||||
-}
|
||||
type Event
|
||||
= Sync Game.LobbyAndHand
|
||||
| PlayerJoin Player
|
||||
| PlayerStatus Player.Id Player.Status
|
||||
| PlayerLeft Player.Id
|
||||
| PlayerDisconnect Player.Id
|
||||
| PlayerReconnect Player.Id
|
||||
| PlayerScoreChange Player.Id Int
|
||||
| HandChange Card.Hand
|
||||
| RoundStart Player.Id Card.Call
|
||||
| RoundPlayed Int
|
||||
| RoundJudging (List Card.PlayedCards)
|
||||
| RoundEnd Round.FinishedRound
|
||||
| GameStart Player.Id Card.Call
|
||||
| GameEnd
|
||||
| ConfigChange Game.Config
|
||||
| RoundTimeLimitHit
|
||||
|
||||
|
||||
fromJson : String -> Result String Event
|
||||
fromJson json =
|
||||
Json.decodeString eventDecoder json
|
||||
|
||||
|
||||
eventDecoder : Json.Decoder Event
|
||||
eventDecoder =
|
||||
(Json.field "event" Json.string) |> Json.andThen specificEventDecoder
|
||||
|
||||
|
||||
specificEventDecoder : String -> Json.Decoder Event
|
||||
specificEventDecoder name =
|
||||
case name of
|
||||
"Sync" ->
|
||||
Json.map Sync (Json.field "lobbyAndHand" lobbyAndHandDecoder)
|
||||
|
||||
"PlayerJoin" ->
|
||||
Json.map PlayerJoin (Json.field "player" playerDecoder)
|
||||
|
||||
"PlayerStatus" ->
|
||||
Json.map2 PlayerStatus (Json.field "player" playerIdDecoder) (Json.field "status" playerStatusDecoder)
|
||||
|
||||
"PlayerLeft" ->
|
||||
Json.map PlayerLeft (Json.field "player" playerIdDecoder)
|
||||
|
||||
"PlayerDisconnect" ->
|
||||
Json.map PlayerDisconnect (Json.field "player" playerIdDecoder)
|
||||
|
||||
"PlayerReconnect" ->
|
||||
Json.map PlayerReconnect (Json.field "player" playerIdDecoder)
|
||||
|
||||
"PlayerScoreChange" ->
|
||||
Json.map2 PlayerScoreChange (Json.field "player" playerIdDecoder) (Json.field "score" Json.int)
|
||||
|
||||
"HandChange" ->
|
||||
Json.map HandChange (Json.field "hand" handDecoder)
|
||||
|
||||
"RoundStart" ->
|
||||
Json.map2 RoundStart (Json.field "czar" playerIdDecoder) (Json.field "call" callDecoder)
|
||||
|
||||
"RoundPlayed" ->
|
||||
Json.map RoundPlayed (Json.field "playedCards" Json.int)
|
||||
|
||||
"RoundJudging" ->
|
||||
Json.map RoundJudging (Json.field "playedCards" (Json.list (Json.list responseDecoder)))
|
||||
|
||||
"RoundEnd" ->
|
||||
Json.map RoundEnd (Json.field "finishedRound" finishedRoundDecoder)
|
||||
|
||||
"GameStart" ->
|
||||
Json.map2 GameStart (Json.field "czar" playerIdDecoder) (Json.field "call" callDecoder)
|
||||
|
||||
"GameEnd" ->
|
||||
Json.succeed GameEnd
|
||||
|
||||
"ConfigChange" ->
|
||||
Json.map ConfigChange (Json.field "config" configDecoder)
|
||||
|
||||
"RoundTimeLimitHit" ->
|
||||
Json.succeed RoundTimeLimitHit
|
||||
|
||||
unknown ->
|
||||
Json.fail (unknown ++ " is not a recognised event.")
|
||||
@@ -1,64 +0,0 @@
|
||||
module MassiveDecks.Models.Game exposing (..)
|
||||
|
||||
import MassiveDecks.Models.Game.Round as Round exposing (Round)
|
||||
import MassiveDecks.Models.Card as Card
|
||||
import MassiveDecks.Models.Player as Player exposing (Player)
|
||||
import MassiveDecks.Scenes.Playing.HouseRule.Id as HouseRule
|
||||
|
||||
|
||||
{-| The required information to rejoin a lobby - the ID and the secret.
|
||||
-}
|
||||
type alias GameCodeAndSecret =
|
||||
{ gameCode : GameCode
|
||||
, secret : Player.Secret
|
||||
}
|
||||
|
||||
|
||||
{-| A lobby ID is a string used to identify a given lobby.
|
||||
-}
|
||||
type alias GameCode =
|
||||
String
|
||||
|
||||
|
||||
{-| Configuration for a game.
|
||||
-}
|
||||
type alias Config =
|
||||
{ decks : List DeckInfo
|
||||
, houseRules : List HouseRule.Id
|
||||
, password : Maybe String
|
||||
}
|
||||
|
||||
|
||||
{-| Information about a deck of cards.
|
||||
-}
|
||||
type alias DeckInfo =
|
||||
{ id : String
|
||||
, name : String
|
||||
, calls : Int
|
||||
, responses : Int
|
||||
}
|
||||
|
||||
|
||||
type State
|
||||
= Configuring
|
||||
| Playing Round
|
||||
| Finished
|
||||
|
||||
|
||||
{-| A lobby.
|
||||
-}
|
||||
type alias Lobby =
|
||||
{ gameCode : String
|
||||
, owner : Player.Id
|
||||
, config : Config
|
||||
, players : List Player
|
||||
, game : State
|
||||
}
|
||||
|
||||
|
||||
{-| A lobby and a player's hand.
|
||||
-}
|
||||
type alias LobbyAndHand =
|
||||
{ lobby : Lobby
|
||||
, hand : Card.Hand
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
module MassiveDecks.Models.Game.Round exposing (..)
|
||||
|
||||
import MassiveDecks.Models.Card as Card
|
||||
import MassiveDecks.Models.Player as Player
|
||||
|
||||
|
||||
type alias Round =
|
||||
{ czar : Player.Id
|
||||
, call : Card.Call
|
||||
, state : State
|
||||
}
|
||||
|
||||
|
||||
{-| The possible states of the round.
|
||||
-}
|
||||
type State
|
||||
= P Playing
|
||||
| J Judging
|
||||
| F Finished
|
||||
|
||||
|
||||
playing : Int -> Bool -> State
|
||||
playing numberPlayed afterTimeLimit =
|
||||
P (Playing numberPlayed afterTimeLimit)
|
||||
|
||||
|
||||
{-| A round that is being played into.
|
||||
-}
|
||||
type alias Playing =
|
||||
{ numberPlayed : Int
|
||||
, afterTimeLimit : Bool
|
||||
}
|
||||
|
||||
|
||||
judging : List Card.PlayedCards -> Bool -> State
|
||||
judging responses afterTimeLimit =
|
||||
J (Judging responses afterTimeLimit)
|
||||
|
||||
|
||||
{-| A round that is being judged.
|
||||
-}
|
||||
type alias Judging =
|
||||
{ responses : List Card.PlayedCards
|
||||
, afterTimeLimit : Bool
|
||||
}
|
||||
|
||||
|
||||
finished : List Card.PlayedCards -> Player.PlayedByAndWinner -> State
|
||||
finished responses playedByAndWinner =
|
||||
F (Finished responses playedByAndWinner)
|
||||
|
||||
|
||||
{-| A round that has been completed.
|
||||
-}
|
||||
type alias Finished =
|
||||
{ responses : List Card.PlayedCards
|
||||
, playedByAndWinner : Player.PlayedByAndWinner
|
||||
}
|
||||
|
||||
|
||||
{-| A historical round that has been completed.
|
||||
-}
|
||||
type alias FinishedRound =
|
||||
{ czar : Player.Id
|
||||
, call : Card.Call
|
||||
, state : Finished
|
||||
}
|
||||
|
||||
|
||||
afterTimeLimit : State -> Bool
|
||||
afterTimeLimit state =
|
||||
case state of
|
||||
P playing ->
|
||||
playing.afterTimeLimit
|
||||
|
||||
J judging ->
|
||||
judging.afterTimeLimit
|
||||
|
||||
F finished ->
|
||||
False
|
||||
|
||||
|
||||
setAfterTimeLimit : Round -> Bool -> Round
|
||||
setAfterTimeLimit round afterTimeLimit =
|
||||
case round.state of
|
||||
P playing ->
|
||||
{ round | state = P { playing | afterTimeLimit = afterTimeLimit } }
|
||||
|
||||
J judging ->
|
||||
{ round | state = J { judging | afterTimeLimit = afterTimeLimit } }
|
||||
|
||||
F finished ->
|
||||
round
|
||||
@@ -1,202 +0,0 @@
|
||||
module MassiveDecks.Models.JSON.Decode exposing (..)
|
||||
|
||||
import Json.Decode exposing (..)
|
||||
import MassiveDecks.Models.Game as Game
|
||||
import MassiveDecks.Models.Game.Round as Round exposing (Round)
|
||||
import MassiveDecks.Models.Player as Player exposing (Player)
|
||||
import MassiveDecks.Models.Card as Card
|
||||
import MassiveDecks.Scenes.Playing.HouseRule.Id as HouseRule
|
||||
|
||||
|
||||
lobbyAndHandDecoder : Decoder Game.LobbyAndHand
|
||||
lobbyAndHandDecoder =
|
||||
map2 Game.LobbyAndHand
|
||||
(field "lobby" lobbyDecoder)
|
||||
(field "hand" handDecoder)
|
||||
|
||||
|
||||
lobbyDecoder : Decoder Game.Lobby
|
||||
lobbyDecoder =
|
||||
map5 Game.Lobby
|
||||
(field "gameCode" string)
|
||||
(field "owner" playerIdDecoder)
|
||||
(field "config" configDecoder)
|
||||
(field "players" (list playerDecoder))
|
||||
(field "state" gameStateDecoder)
|
||||
|
||||
|
||||
gameCodeAndSecretDecoder : Decoder Game.GameCodeAndSecret
|
||||
gameCodeAndSecretDecoder =
|
||||
map2 Game.GameCodeAndSecret
|
||||
(field "gameCode" string)
|
||||
(field "secret" playerSecretDecoder)
|
||||
|
||||
|
||||
deckInfoDecoder : Decoder Game.DeckInfo
|
||||
deckInfoDecoder =
|
||||
map4 Game.DeckInfo
|
||||
(field "id" string)
|
||||
(field "name" string)
|
||||
(field "calls" int)
|
||||
(field "responses" int)
|
||||
|
||||
|
||||
configDecoder : Decoder Game.Config
|
||||
configDecoder =
|
||||
map3 Game.Config
|
||||
(field "decks" (list deckInfoDecoder))
|
||||
(field "houseRules" (list houseRuleDecoder))
|
||||
(maybe (field "password" string))
|
||||
|
||||
|
||||
handDecoder : Decoder Card.Hand
|
||||
handDecoder =
|
||||
map Card.Hand
|
||||
(field "hand" (list responseDecoder))
|
||||
|
||||
|
||||
playerDecoder : Decoder Player
|
||||
playerDecoder =
|
||||
map6 Player
|
||||
(field "id" playerIdDecoder)
|
||||
(field "name" string)
|
||||
(field "status" playerStatusDecoder)
|
||||
(field "score" int)
|
||||
(field "disconnected" bool)
|
||||
(field "left" bool)
|
||||
|
||||
|
||||
playerStatusDecoder : Decoder Player.Status
|
||||
playerStatusDecoder =
|
||||
customDecoder (string) (\name -> Player.nameToStatus name |> Result.fromMaybe ("Unknown player status '" ++ name ++ "'."))
|
||||
|
||||
|
||||
gameStateDecoder : Decoder Game.State
|
||||
gameStateDecoder =
|
||||
(field "gameState" string
|
||||
|> andThen
|
||||
(\gameState ->
|
||||
case gameState of
|
||||
"configuring" ->
|
||||
succeed Game.Configuring
|
||||
|
||||
"playing" ->
|
||||
map Game.Playing (field "round" roundDecoder)
|
||||
|
||||
"finished" ->
|
||||
succeed Game.Finished
|
||||
|
||||
_ ->
|
||||
fail ("Unknown game state '" ++ gameState ++ "'.")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
roundDecoder : Decoder Round
|
||||
roundDecoder =
|
||||
map3 Round
|
||||
(field "czar" playerIdDecoder)
|
||||
(field "call" callDecoder)
|
||||
(field "state" roundStateDecoder)
|
||||
|
||||
|
||||
roundStateDecoder : Decoder Round.State
|
||||
roundStateDecoder =
|
||||
(field "roundState" string
|
||||
|> andThen
|
||||
(\roundState ->
|
||||
case roundState of
|
||||
"playing" ->
|
||||
map2 Round.playing
|
||||
(field "numberPlayed" int)
|
||||
(field "afterTimeLimit" bool)
|
||||
|
||||
"judging" ->
|
||||
map2 Round.judging
|
||||
(field "cards" (list (list responseDecoder)))
|
||||
(field "afterTimeLimit" bool)
|
||||
|
||||
"finished" ->
|
||||
map Round.F finishedStateDecoder
|
||||
|
||||
_ ->
|
||||
fail ("Unknown round state '" ++ roundState ++ "'.")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
finishedStateDecoder : Decoder Round.Finished
|
||||
finishedStateDecoder =
|
||||
map2 Round.Finished
|
||||
(field "cards" (list (list responseDecoder)))
|
||||
(field "playedByAndWinner" playedByAndWinnerDecoder)
|
||||
|
||||
|
||||
finishedRoundDecoder : Decoder Round.FinishedRound
|
||||
finishedRoundDecoder =
|
||||
map3 Round.FinishedRound
|
||||
(field "czar" playerIdDecoder)
|
||||
(field "call" callDecoder)
|
||||
(field "state" finishedStateDecoder)
|
||||
|
||||
|
||||
playedByAndWinnerDecoder : Decoder Player.PlayedByAndWinner
|
||||
playedByAndWinnerDecoder =
|
||||
map2 Player.PlayedByAndWinner
|
||||
(field "playedBy" (list (playerIdDecoder)))
|
||||
(field "winner" playerIdDecoder)
|
||||
|
||||
|
||||
callDecoder : Decoder Card.Call
|
||||
callDecoder =
|
||||
map2 Card.Call
|
||||
(field "id" string)
|
||||
(field "parts" (list string))
|
||||
|
||||
|
||||
responseDecoder : Decoder Card.Response
|
||||
responseDecoder =
|
||||
map2 Card.Response
|
||||
(field "id" string)
|
||||
(field "text" string)
|
||||
|
||||
|
||||
playerIdDecoder : Decoder Player.Id
|
||||
playerIdDecoder =
|
||||
int
|
||||
|
||||
|
||||
playerSecretDecoder : Decoder Player.Secret
|
||||
playerSecretDecoder =
|
||||
map2 Player.Secret
|
||||
(field "id" playerIdDecoder)
|
||||
(field "secret" string)
|
||||
|
||||
|
||||
houseRuleDecoder : Decoder HouseRule.Id
|
||||
houseRuleDecoder =
|
||||
customDecoder (string) (\name -> ruleNameToId name |> Result.fromMaybe ("Unknown house rule '" ++ name ++ "'."))
|
||||
|
||||
|
||||
ruleNameToId : String -> Maybe HouseRule.Id
|
||||
ruleNameToId name =
|
||||
case name of
|
||||
"reboot" ->
|
||||
Just HouseRule.Reboot
|
||||
|
||||
_ ->
|
||||
Nothing
|
||||
|
||||
|
||||
customDecoder : Decoder b -> (b -> Result String a) -> Decoder a
|
||||
customDecoder decoder toResult =
|
||||
andThen
|
||||
(\a ->
|
||||
case toResult a of
|
||||
Ok b ->
|
||||
succeed b
|
||||
|
||||
Err err ->
|
||||
fail err
|
||||
)
|
||||
decoder
|
||||
@@ -1,46 +0,0 @@
|
||||
module MassiveDecks.Models.JSON.Encode exposing (..)
|
||||
|
||||
import Json.Encode as Json
|
||||
import MassiveDecks.Models.Player as Player
|
||||
|
||||
|
||||
encodeCommand : String -> Player.Secret -> List ( String, Json.Value ) -> Json.Value
|
||||
encodeCommand action playerSecret rest =
|
||||
Json.object
|
||||
(List.append
|
||||
[ ( "command", Json.string action )
|
||||
, ( "secret", encodePlayerSecret playerSecret )
|
||||
]
|
||||
rest
|
||||
)
|
||||
|
||||
|
||||
encodeDeckId : String -> ( String, Json.Value )
|
||||
encodeDeckId id =
|
||||
( "deckId", Json.string id )
|
||||
|
||||
|
||||
encodePlayerSecret : Player.Secret -> Json.Value
|
||||
encodePlayerSecret playerSecret =
|
||||
Json.object
|
||||
[ ( "id", encodePlayerId playerSecret.id )
|
||||
, ( "secret", Json.string playerSecret.secret )
|
||||
]
|
||||
|
||||
|
||||
encodePlayerId : Player.Id -> Json.Value
|
||||
encodePlayerId playerId =
|
||||
Json.int playerId
|
||||
|
||||
|
||||
encodeName : String -> Json.Value
|
||||
encodeName name =
|
||||
Json.object [ ( "name", Json.string name ) ]
|
||||
|
||||
|
||||
encodeNameAndPassword : String -> String -> Json.Value
|
||||
encodeNameAndPassword name password =
|
||||
Json.object
|
||||
[ ( "name", Json.string name )
|
||||
, ( "password", Json.string password )
|
||||
]
|
||||
@@ -1,55 +0,0 @@
|
||||
module MassiveDecks.Models.Notification exposing (..)
|
||||
|
||||
import MassiveDecks.Models.Player as Player exposing (Player)
|
||||
|
||||
|
||||
{-| A notification about a player.
|
||||
-}
|
||||
type alias Notification =
|
||||
{ icon : String
|
||||
, name : String
|
||||
, description : String
|
||||
, visible : Bool
|
||||
}
|
||||
|
||||
|
||||
{-| Hide the given notificaiton.
|
||||
-}
|
||||
hide : Notification -> Notification
|
||||
hide notification =
|
||||
{ notification | visible = False }
|
||||
|
||||
|
||||
{-| Create a notification for a player joining the game.
|
||||
-}
|
||||
playerJoin : Player.Id -> List Player -> Maybe Notification
|
||||
playerJoin id players =
|
||||
playerFromIdAndPlayers id players "sign-in" " has joined the game."
|
||||
|
||||
|
||||
{-| Create a notification for a player reconnecting to the game.
|
||||
-}
|
||||
playerReconnect : Player.Id -> List Player -> Maybe Notification
|
||||
playerReconnect id players =
|
||||
playerFromIdAndPlayers id players "sign-in" " has reconnected to the game."
|
||||
|
||||
|
||||
{-| Create a notification for a player disconnecting from the game.
|
||||
-}
|
||||
playerDisconnect : Player.Id -> List Player -> Maybe Notification
|
||||
playerDisconnect id players =
|
||||
playerFromIdAndPlayers id players "minus-circle" " has disconnected from the game."
|
||||
|
||||
|
||||
{-| Create a notification for a player leaving the game.
|
||||
-}
|
||||
playerLeft : Player.Id -> List Player -> Maybe Notification
|
||||
playerLeft id players =
|
||||
playerFromIdAndPlayers id players "sign-out" " has left the game."
|
||||
|
||||
|
||||
playerFromIdAndPlayers : Player.Id -> List Player -> String -> String -> Maybe Notification
|
||||
playerFromIdAndPlayers id players icon suffix =
|
||||
Player.byId id players
|
||||
|> Maybe.map .name
|
||||
|> Maybe.map (\name -> Notification icon name (name ++ suffix) True)
|
||||
@@ -1,106 +0,0 @@
|
||||
module MassiveDecks.Models.Player exposing (..)
|
||||
|
||||
import MassiveDecks.Util as Util
|
||||
|
||||
|
||||
{-| A game-unique identifier for a player.
|
||||
-}
|
||||
type alias Id =
|
||||
Int
|
||||
|
||||
|
||||
{-| A player.
|
||||
-}
|
||||
type alias Player =
|
||||
{ id : Id
|
||||
, name : String
|
||||
, status : Status
|
||||
, score : Int
|
||||
, disconnected : Bool
|
||||
, left : Bool
|
||||
}
|
||||
|
||||
|
||||
{-| A list of ids to identify who played what responses into a round and the id of the winner of the round.
|
||||
-}
|
||||
type alias PlayedByAndWinner =
|
||||
{ playedBy : List Id
|
||||
, winner : Id
|
||||
}
|
||||
|
||||
|
||||
{-| A secret that a player uses to authenticate themselves to the server.
|
||||
-}
|
||||
type alias Secret =
|
||||
{ id : Id
|
||||
, secret : String
|
||||
}
|
||||
|
||||
|
||||
{-| The status of a player.
|
||||
-}
|
||||
type Status
|
||||
= NotPlayed
|
||||
| Played
|
||||
| Czar
|
||||
| Ai
|
||||
| Neutral
|
||||
| Skipping
|
||||
|
||||
|
||||
{-| The name of the status.
|
||||
-}
|
||||
statusName : Status -> String
|
||||
statusName status =
|
||||
case status of
|
||||
NotPlayed ->
|
||||
"not-played"
|
||||
|
||||
Played ->
|
||||
"played"
|
||||
|
||||
Czar ->
|
||||
"czar"
|
||||
|
||||
Ai ->
|
||||
"ai"
|
||||
|
||||
Neutral ->
|
||||
"neutral"
|
||||
|
||||
Skipping ->
|
||||
"skipping"
|
||||
|
||||
|
||||
{-| Get a status from a name.
|
||||
-}
|
||||
nameToStatus : String -> Maybe Status
|
||||
nameToStatus name =
|
||||
case name of
|
||||
"not-played" ->
|
||||
Just NotPlayed
|
||||
|
||||
"played" ->
|
||||
Just Played
|
||||
|
||||
"czar" ->
|
||||
Just Czar
|
||||
|
||||
"ai" ->
|
||||
Just Ai
|
||||
|
||||
"neutral" ->
|
||||
Just Neutral
|
||||
|
||||
"skipping" ->
|
||||
Just Skipping
|
||||
|
||||
_ ->
|
||||
Nothing
|
||||
|
||||
|
||||
{-| Get a player from a list of players by their id.
|
||||
-}
|
||||
byId : Id -> List Player -> Maybe Player
|
||||
byId id players =
|
||||
Util.find (\player -> player.id == id) players
|
||||
@@ -1,164 +0,0 @@
|
||||
module MassiveDecks.Scenes.Config exposing (update, view, init, subscriptions)
|
||||
|
||||
import String
|
||||
import Html exposing (Html)
|
||||
import MassiveDecks.API as API
|
||||
import MassiveDecks.API.Request as Request
|
||||
import MassiveDecks.Components.Input as Input
|
||||
import MassiveDecks.Components.Errors as Errors
|
||||
import MassiveDecks.Scenes.Lobby.Models as Lobby
|
||||
import MassiveDecks.Scenes.Config.Messages exposing (ConsumerMessage(..), Message(..), InputId(..), Deck(..))
|
||||
import MassiveDecks.Scenes.Config.Models exposing (Model)
|
||||
import MassiveDecks.Scenes.Config.UI as UI
|
||||
import MassiveDecks.Models.Game as Game
|
||||
import MassiveDecks.Models.Player as Player
|
||||
import MassiveDecks.Util as Util exposing ((:>))
|
||||
|
||||
|
||||
{-| Create the initial model for the config screen.
|
||||
-}
|
||||
init : Game.Lobby -> Player.Secret -> ( Model, Cmd ConsumerMessage )
|
||||
init lobby secret =
|
||||
let
|
||||
canChangeConfig =
|
||||
lobby.owner == secret.id
|
||||
|
||||
config =
|
||||
lobby.config
|
||||
|
||||
setPasswordButton =
|
||||
if canChangeConfig then
|
||||
UI.setPasswordButton
|
||||
else
|
||||
(\_ -> [])
|
||||
in
|
||||
{ decks = []
|
||||
, deckIdInput = Input.initWithExtra DeckId "input-with-button" UI.deckIdInputLabel "" "Play Code" UI.addDeckButton (Util.cmd AddDeck) InputMessage
|
||||
, passwordInput = Input.initWithExtra Password "input-with-button" UI.passwordInputLabel (config.password |> Maybe.withDefault "") "Password" setPasswordButton (Util.cmd SetPassword) InputMessage
|
||||
, loadingDecks = []
|
||||
}
|
||||
! [ ( Password, Input.SetEnabled canChangeConfig ) |> InputMessage |> LocalMessage |> Util.cmd ]
|
||||
|
||||
|
||||
{-| Subscriptions for the config screen.
|
||||
-}
|
||||
subscriptions : Model -> Sub ConsumerMessage
|
||||
subscriptions model =
|
||||
Sub.none
|
||||
|
||||
|
||||
{-| Render the config screen.
|
||||
-}
|
||||
view : Lobby.Model -> Html ConsumerMessage
|
||||
view lobbyModel =
|
||||
UI.view lobbyModel |> Html.map LocalMessage
|
||||
|
||||
|
||||
{-| Handles messages and alters the model as appropriate.
|
||||
-}
|
||||
update : Message -> Lobby.Model -> ( Model, Cmd ConsumerMessage )
|
||||
update message lobbyModel =
|
||||
let
|
||||
lobby =
|
||||
lobbyModel.lobby
|
||||
|
||||
gameCode =
|
||||
lobbyModel.lobby.gameCode
|
||||
|
||||
secret =
|
||||
lobbyModel.secret
|
||||
|
||||
model =
|
||||
lobbyModel.config
|
||||
in
|
||||
case message of
|
||||
ConfigureDecks (Request rawDeckId) ->
|
||||
let
|
||||
deckId =
|
||||
String.toUpper rawDeckId
|
||||
in
|
||||
{ model | loadingDecks = model.loadingDecks ++ [ deckId ] }
|
||||
! [ Request.send (API.addDeck gameCode secret deckId) (addDeckErrorHandler deckId) ErrorMessage (\_ -> (Add deckId) |> ConfigureDecks |> LocalMessage)
|
||||
, inputClearErrorCmd DeckId
|
||||
]
|
||||
|
||||
AddDeck ->
|
||||
( model, Util.cmd (LocalMessage (ConfigureDecks (Request model.deckIdInput.value))) )
|
||||
|
||||
ConfigureDecks (Add deckId) ->
|
||||
( removeDeckLoadingSpinner deckId model, Cmd.none )
|
||||
|
||||
ConfigureDecks (Fail deckId errorMessage) ->
|
||||
( removeDeckLoadingSpinner deckId model, inputSetErrorCmd DeckId errorMessage )
|
||||
|
||||
InputMessage message ->
|
||||
let
|
||||
( deckIdInput, deckIdMsg ) =
|
||||
Input.update message lobbyModel.config.deckIdInput
|
||||
|
||||
( passwordInput, passwordMsg ) =
|
||||
Input.update message lobbyModel.config.passwordInput
|
||||
in
|
||||
{ model
|
||||
| deckIdInput = deckIdInput
|
||||
, passwordInput = passwordInput
|
||||
}
|
||||
! [ Cmd.map LocalMessage deckIdMsg, Cmd.map LocalMessage passwordMsg ]
|
||||
|
||||
AddAi ->
|
||||
( model, Request.send_ (API.newAi gameCode secret) ErrorMessage (\_ -> LocalMessage NoOp) )
|
||||
|
||||
StartGame ->
|
||||
( model, Request.send (API.newGame gameCode secret) newGameErrorHandler ErrorMessage HandUpdate )
|
||||
|
||||
EnableRule rule ->
|
||||
( model, Request.send_ (API.enableRule rule gameCode secret) ErrorMessage ignore )
|
||||
|
||||
DisableRule rule ->
|
||||
( model, Request.send_ (API.disableRule rule gameCode secret) ErrorMessage ignore )
|
||||
|
||||
SetPassword ->
|
||||
( model, Request.send_ (API.setPassword gameCode secret model.passwordInput.value) ErrorMessage ignore )
|
||||
|
||||
NoOp ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
ignore : () -> ConsumerMessage
|
||||
ignore =
|
||||
(\_ -> LocalMessage NoOp)
|
||||
|
||||
|
||||
inputClearErrorCmd : InputId -> Cmd ConsumerMessage
|
||||
inputClearErrorCmd inputId =
|
||||
( inputId, Nothing |> Input.Error ) |> InputMessage |> LocalMessage |> Util.cmd
|
||||
|
||||
|
||||
inputSetErrorCmd : InputId -> String -> Cmd ConsumerMessage
|
||||
inputSetErrorCmd inputId error =
|
||||
( inputId, Just error |> Input.Error ) |> InputMessage |> LocalMessage |> Util.cmd
|
||||
|
||||
|
||||
removeDeckLoadingSpinner : String -> Model -> Model
|
||||
removeDeckLoadingSpinner deckId model =
|
||||
{ model | loadingDecks = List.filter ((/=) deckId) model.loadingDecks }
|
||||
|
||||
|
||||
addDeckErrorHandler : String -> API.AddDeckError -> ConsumerMessage
|
||||
addDeckErrorHandler deckId error =
|
||||
case error of
|
||||
API.CardcastTimeout ->
|
||||
ConfigureDecks (Fail deckId "There was a problem accessing CardCast, please try again after a short wait.") |> LocalMessage
|
||||
|
||||
API.DeckNotFound ->
|
||||
ConfigureDecks (Fail deckId "The given play code doesn't exist, please check you have the correct code.") |> LocalMessage
|
||||
|
||||
|
||||
newGameErrorHandler : API.NewGameError -> ConsumerMessage
|
||||
newGameErrorHandler error =
|
||||
case error of
|
||||
API.GameInProgress ->
|
||||
ErrorMessage <| Errors.New "Can't start the game - it is already in progress." False
|
||||
|
||||
API.NotEnoughPlayers required ->
|
||||
ErrorMessage <| Errors.New ("Can't start the game - you need at least " ++ (toString required) ++ " players to start the game.") False
|
||||
@@ -1,49 +0,0 @@
|
||||
module MassiveDecks.Scenes.Config.Messages exposing (..)
|
||||
|
||||
import MassiveDecks.Components.Input as Input
|
||||
import MassiveDecks.Components.Errors as Errors
|
||||
import MassiveDecks.Models.Card as Card
|
||||
import MassiveDecks.Scenes.Playing.HouseRule.Id as HouseRule
|
||||
|
||||
|
||||
{-| This type is used for all sending of messages, allowing us to send messages handled outside this scene.
|
||||
-}
|
||||
type ConsumerMessage
|
||||
= HandUpdate Card.Hand
|
||||
| ErrorMessage Errors.Message
|
||||
| LocalMessage Message
|
||||
|
||||
|
||||
{-| The messages used in the start screen.
|
||||
-}
|
||||
type Message
|
||||
= AddDeck
|
||||
| ConfigureDecks Deck
|
||||
| InputMessage (Input.Message InputId)
|
||||
| AddAi
|
||||
| StartGame
|
||||
| EnableRule HouseRule.Id
|
||||
| DisableRule HouseRule.Id
|
||||
| SetPassword
|
||||
| NoOp
|
||||
|
||||
|
||||
type Deck
|
||||
= Request DeckId
|
||||
| Add DeckId
|
||||
| Fail DeckId FailureMessage
|
||||
|
||||
|
||||
type alias DeckId =
|
||||
String
|
||||
|
||||
|
||||
type alias FailureMessage =
|
||||
String
|
||||
|
||||
|
||||
{-| IDs for the inputs to differentiate between them in messages.
|
||||
-}
|
||||
type InputId
|
||||
= DeckId
|
||||
| Password
|
||||
@@ -1,15 +0,0 @@
|
||||
module MassiveDecks.Scenes.Config.Models exposing (..)
|
||||
|
||||
import MassiveDecks.Components.Input as Input
|
||||
import MassiveDecks.Models.Game as Game
|
||||
import MassiveDecks.Scenes.Config.Messages exposing (Message, InputId)
|
||||
|
||||
|
||||
{-| The state of the config screen.
|
||||
-}
|
||||
type alias Model =
|
||||
{ decks : List Game.DeckInfo
|
||||
, deckIdInput : Input.Model InputId Message
|
||||
, passwordInput : Input.Model InputId Message
|
||||
, loadingDecks : List String
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
module MassiveDecks.Scenes.Config.UI exposing (view, deckIdInputLabel, passwordInputLabel, addDeckButton, setPasswordButton)
|
||||
|
||||
import String
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import MassiveDecks.Components.Icon as Icon
|
||||
import MassiveDecks.Components.Input as Input
|
||||
import MassiveDecks.Models.Game as Game
|
||||
import MassiveDecks.Scenes.Lobby.Models as Lobby
|
||||
import MassiveDecks.Scenes.Playing.HouseRule as HouseRule exposing (HouseRule)
|
||||
import MassiveDecks.Scenes.Playing.HouseRule.Id as HouseRule
|
||||
import MassiveDecks.Scenes.Playing.HouseRule.Available exposing (houseRules)
|
||||
import MassiveDecks.Scenes.Config.Messages exposing (Message(..), InputId(..), Deck(..))
|
||||
import MassiveDecks.Util as Util
|
||||
|
||||
|
||||
view : Lobby.Model -> Html Message
|
||||
view lobbyModel =
|
||||
let
|
||||
model =
|
||||
lobbyModel.config
|
||||
|
||||
lobby =
|
||||
lobbyModel.lobby
|
||||
|
||||
decks =
|
||||
lobby.config.decks
|
||||
|
||||
enoughPlayers =
|
||||
((List.length lobby.players) > 1)
|
||||
|
||||
enoughCards =
|
||||
not (List.isEmpty decks)
|
||||
|
||||
canNotChangeConfig =
|
||||
lobbyModel.lobby.owner /= lobbyModel.secret.id
|
||||
in
|
||||
div [ id "config" ]
|
||||
((if canNotChangeConfig then
|
||||
infoBar
|
||||
else
|
||||
[]
|
||||
)
|
||||
++ [ div [ id "config-content", class "mui-panel" ]
|
||||
[ invite lobbyModel.init.url lobby.gameCode
|
||||
, div [ class "mui-divider" ] []
|
||||
, h1 [] [ text "Game Setup" ]
|
||||
, ul [ class "mui-tabs__bar" ]
|
||||
[ li [ class "mui--is-active" ]
|
||||
[ a
|
||||
[ attribute "data-mui-toggle" "tab"
|
||||
, attribute "data-mui-controls" "decks"
|
||||
]
|
||||
[ text "Decks" ]
|
||||
]
|
||||
, li []
|
||||
[ a
|
||||
[ attribute "data-mui-toggle" "tab"
|
||||
, attribute "data-mui-controls" "house-rules"
|
||||
]
|
||||
[ text "House Rules" ]
|
||||
]
|
||||
, li []
|
||||
[ a
|
||||
[ attribute "data-mui-toggle" "tab"
|
||||
, attribute "data-mui-controls" "lobby-settings"
|
||||
]
|
||||
[ text "Lobby Settings" ]
|
||||
]
|
||||
]
|
||||
, div [ id "decks", class "mui-tabs__pane mui--is-active" ]
|
||||
[ deckList canNotChangeConfig decks model.loadingDecks model.deckIdInput ]
|
||||
, div [ id "house-rules", class "mui-tabs__pane" ]
|
||||
([ rando canNotChangeConfig ] ++ (List.map (\rule -> houseRule canNotChangeConfig (List.member rule.id lobbyModel.lobby.config.houseRules) rule) houseRules))
|
||||
, div [ id "lobby-settings", class "mui-tabs__pane" ]
|
||||
[ password model.passwordInput ]
|
||||
, div [ class "mui-divider" ] []
|
||||
, startGameButton canNotChangeConfig enoughPlayers enoughCards
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
infoBar : List (Html msg)
|
||||
infoBar =
|
||||
[ div [ id "info-bar", class "mui--z1" ]
|
||||
[ Icon.icon "info-circle"
|
||||
, text " "
|
||||
, text "You can't change the configuration of the game, as you are not the owner."
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
passwordInputLabel : List (Html msg)
|
||||
passwordInputLabel =
|
||||
[ text "If blank, anyone with the game code can join." ]
|
||||
|
||||
|
||||
password : Input.Model InputId Message -> Html Message
|
||||
password passwordInputModel =
|
||||
div []
|
||||
[ h3 [] [ Icon.icon "key", text " Privacy" ]
|
||||
, p [] [ text "A password that players will need to enter to get in the game. People already in the game will not need to enter it, and anyone in the game will be able to see it." ]
|
||||
, Input.view passwordInputModel
|
||||
]
|
||||
|
||||
|
||||
setPasswordButton : String -> List (Html Message)
|
||||
setPasswordButton password =
|
||||
[ button
|
||||
[ class "mui-btn mui-btn--small mui-btn--primary"
|
||||
, onClick SetPassword
|
||||
, title "Set the password."
|
||||
]
|
||||
[ Icon.icon "lock" ]
|
||||
]
|
||||
|
||||
|
||||
invite : String -> String -> Html msg
|
||||
invite appUrl lobbyId =
|
||||
let
|
||||
url =
|
||||
Util.lobbyUrl appUrl lobbyId
|
||||
in
|
||||
div []
|
||||
[ p []
|
||||
[ text "Invite others to the game with the code '"
|
||||
, strong [ class "game-code" ] [ text lobbyId ]
|
||||
, text "' to enter on the main page, or give them this link: "
|
||||
]
|
||||
, p [] [ a [ href url ] [ text url ] ]
|
||||
]
|
||||
|
||||
|
||||
deckIdInputLabel : List (Html msg)
|
||||
deckIdInputLabel =
|
||||
[ text " A "
|
||||
, a [ href "https://www.cardcastgame.com/browse", target "_blank", rel "noopener" ] [ text "Cardcast" ]
|
||||
, text " Play Code"
|
||||
]
|
||||
|
||||
|
||||
addDeckButton : String -> List (Html Message)
|
||||
addDeckButton deckId =
|
||||
[ button
|
||||
[ class "mui-btn mui-btn--small mui-btn--primary mui-btn--fab"
|
||||
, disabled (String.isEmpty deckId)
|
||||
, onClick (ConfigureDecks (Request deckId))
|
||||
, title "Add deck to game."
|
||||
]
|
||||
[ Icon.icon "plus" ]
|
||||
]
|
||||
|
||||
|
||||
deckList : Bool -> List Game.DeckInfo -> List String -> Input.Model InputId Message -> Html Message
|
||||
deckList canNotChangeConfig decks loadingDecks deckId =
|
||||
table [ class "decks mui-table" ]
|
||||
[ thead []
|
||||
[ tr []
|
||||
[ th [] [ text "Id" ]
|
||||
, th [] [ text "Name" ]
|
||||
, th [ title "Calls" ] [ Icon.icon "square" ]
|
||||
, th [ title "Responses" ] [ Icon.icon "square-o" ]
|
||||
]
|
||||
]
|
||||
, Util.tbody []
|
||||
(List.concat
|
||||
[ if (canNotChangeConfig && (List.isEmpty decks)) then
|
||||
[ ( "!!emptyInfo", tr [] [ td [ colspan 4 ] [ text "No decks have been added yet." ] ] ) ]
|
||||
else
|
||||
[]
|
||||
, emptyDeckListInfo ((not canNotChangeConfig) && (List.isEmpty decks) && List.isEmpty loadingDecks)
|
||||
, List.map loadedDeckEntry decks
|
||||
, List.map loadingDeckEntry loadingDecks
|
||||
, if (canNotChangeConfig) then
|
||||
[]
|
||||
else
|
||||
[ ( "!!input", tr [] [ td [ colspan 4 ] [ Input.view deckId ] ] ) ]
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
loadedDeckEntry : Game.DeckInfo -> ( String, Html msg )
|
||||
loadedDeckEntry deck =
|
||||
let
|
||||
row =
|
||||
tr []
|
||||
[ td [] [ deckLink deck.id ]
|
||||
, td [ title deck.name ] [ text deck.name ]
|
||||
, td [] [ text (toString deck.calls) ]
|
||||
, td [] [ text (toString deck.responses) ]
|
||||
]
|
||||
in
|
||||
( deck.id, row )
|
||||
|
||||
|
||||
loadingDeckEntry : String -> ( String, Html msg )
|
||||
loadingDeckEntry deckId =
|
||||
let
|
||||
row =
|
||||
tr [] [ td [] [ deckLink deckId ], td [ colspan 3 ] [ Icon.spinner ] ]
|
||||
in
|
||||
( deckId, row )
|
||||
|
||||
|
||||
deckLink : String -> Html msg
|
||||
deckLink id =
|
||||
a [ href ("https://www.cardcastgame.com/browse/deck/" ++ id), target "_blank", rel "noopener" ] [ text id ]
|
||||
|
||||
|
||||
emptyDeckListInfo : Bool -> List ( String, Html Message )
|
||||
emptyDeckListInfo display =
|
||||
if display then
|
||||
[ ( "!!emptyInfo"
|
||||
, tr []
|
||||
[ td [ colspan 4 ]
|
||||
[ Icon.icon "info-circle"
|
||||
, text " You will need to add at least one "
|
||||
, a [ href "https://www.cardcastgame.com/browse", target "_blank", rel "noopener" ] [ text "Cardcast deck" ]
|
||||
, text " to the game."
|
||||
, text " Not sure? Try "
|
||||
, a
|
||||
[ class "link"
|
||||
, attribute "tabindex" "0"
|
||||
, attribute "role" "button"
|
||||
, onClick (ConfigureDecks (Request "CAHBS"))
|
||||
]
|
||||
[ text "clicking here to add the Cards Against Humanity base set" ]
|
||||
, text "."
|
||||
]
|
||||
]
|
||||
)
|
||||
]
|
||||
else
|
||||
[]
|
||||
|
||||
|
||||
houseRule : Bool -> Bool -> HouseRule -> Html Message
|
||||
houseRule canNotChangeConfig enabled rule =
|
||||
let
|
||||
( buttonText, command ) =
|
||||
if enabled then
|
||||
( "Disable", DisableRule rule.id )
|
||||
else
|
||||
( "Enable", EnableRule rule.id )
|
||||
in
|
||||
houseRuleTemplate canNotChangeConfig (HouseRule.toString rule.id) rule.name rule.icon rule.description buttonText command
|
||||
|
||||
|
||||
houseRuleTemplate : Bool -> String -> String -> String -> String -> String -> msg -> Html msg
|
||||
houseRuleTemplate canNotChangeConfig id_ title icon description buttonText message =
|
||||
div [ id id_, class "house-rule" ]
|
||||
[ div []
|
||||
[ h3 [] [ Icon.icon icon, text " ", text title ]
|
||||
, button [ class "mui-btn mui-btn--small mui-btn--primary", onClick message, disabled canNotChangeConfig ]
|
||||
[ text buttonText ]
|
||||
]
|
||||
, p [] [ text description ]
|
||||
]
|
||||
|
||||
|
||||
rando : Bool -> Html Message
|
||||
rando canNotChangeConfig =
|
||||
houseRuleTemplate canNotChangeConfig
|
||||
"rando"
|
||||
"Rando Cardrissian"
|
||||
"cogs"
|
||||
"Every round, one random card will be played for an imaginary player named Rando Cardrissian, if he wins, all players go home in a state of everlasting shame."
|
||||
"Add an AI player"
|
||||
AddAi
|
||||
|
||||
|
||||
startGameWarning : Bool -> Html msg
|
||||
startGameWarning canStart =
|
||||
if canStart then
|
||||
text ""
|
||||
else
|
||||
span [] [ Icon.icon "info-circle", text " You will need at least two players to start the game." ]
|
||||
|
||||
|
||||
startGameButton : Bool -> Bool -> Bool -> Html Message
|
||||
startGameButton notOwner enoughPlayers enoughCards =
|
||||
div [ id "start-game" ]
|
||||
[ startGameWarning enoughPlayers
|
||||
, button
|
||||
[ class "mui-btn mui-btn--primary mui-btn--raised"
|
||||
, onClick StartGame
|
||||
, disabled ((not (enoughPlayers && enoughCards)) || notOwner)
|
||||
]
|
||||
[ text "Start Game" ]
|
||||
]
|
||||
@@ -1,40 +0,0 @@
|
||||
module MassiveDecks.Scenes.History exposing (init, subscriptions, view, update)
|
||||
|
||||
import Html exposing (Html)
|
||||
import MassiveDecks.API as API
|
||||
import MassiveDecks.API.Request as Request
|
||||
import MassiveDecks.Scenes.History.Messages exposing (Message(..), ConsumerMessage(..))
|
||||
import MassiveDecks.Scenes.History.Models exposing (Model)
|
||||
import MassiveDecks.Scenes.History.UI as UI
|
||||
import MassiveDecks.Models.Player as Player exposing (Player)
|
||||
|
||||
|
||||
init : String -> ( Model, Cmd ConsumerMessage )
|
||||
init gameCode =
|
||||
( { rounds = Nothing
|
||||
}
|
||||
, Request.send_ (API.getHistory gameCode) ErrorMessage (LocalMessage << Load)
|
||||
)
|
||||
|
||||
|
||||
{-| Subscriptions for the playing scene.
|
||||
-}
|
||||
subscriptions : Model -> Sub ConsumerMessage
|
||||
subscriptions model =
|
||||
Sub.none
|
||||
|
||||
|
||||
{-| Render the history.
|
||||
-}
|
||||
view : Model -> List Player -> Html ConsumerMessage
|
||||
view =
|
||||
UI.view
|
||||
|
||||
|
||||
{-| Update the model.
|
||||
-}
|
||||
update : Message -> Model -> ( Model, Cmd ConsumerMessage )
|
||||
update message model =
|
||||
case message of
|
||||
Load rounds ->
|
||||
( { model | rounds = Just rounds }, Cmd.none )
|
||||
@@ -1,18 +0,0 @@
|
||||
module MassiveDecks.Scenes.History.Messages exposing (..)
|
||||
|
||||
import MassiveDecks.Components.Errors as Errors
|
||||
import MassiveDecks.Models.Game.Round as Round
|
||||
|
||||
|
||||
{-| This type is used for all sending of messages, allowing us to send messages handled outside this scene.
|
||||
-}
|
||||
type ConsumerMessage
|
||||
= ErrorMessage Errors.Message
|
||||
| Close
|
||||
| LocalMessage Message
|
||||
|
||||
|
||||
{-| The messages used in the history scene.
|
||||
-}
|
||||
type Message
|
||||
= Load (List Round.FinishedRound)
|
||||
@@ -1,10 +0,0 @@
|
||||
module MassiveDecks.Scenes.History.Models exposing (Model)
|
||||
|
||||
import MassiveDecks.Models.Game.Round as Round
|
||||
|
||||
|
||||
{-| The state of the lobby.
|
||||
-}
|
||||
type alias Model =
|
||||
{ rounds : Maybe (List Round.FinishedRound)
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
module MassiveDecks.Scenes.History.UI exposing (view)
|
||||
|
||||
import Dict
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import Html.Keyed as Keyed
|
||||
import MassiveDecks.Scenes.History.Models exposing (Model)
|
||||
import MassiveDecks.Scenes.History.Messages exposing (ConsumerMessage(..))
|
||||
import MassiveDecks.Scenes.Playing.UI.Cards as CardsUI
|
||||
import MassiveDecks.Models.Game.Round as Round exposing (Round)
|
||||
import MassiveDecks.Models.Card as Card
|
||||
import MassiveDecks.Models.Player as Player exposing (Player)
|
||||
import MassiveDecks.Components.Icon as Icon
|
||||
|
||||
|
||||
view : Model -> List Player -> Html ConsumerMessage
|
||||
view model players =
|
||||
let
|
||||
content =
|
||||
case model.rounds of
|
||||
Just rounds ->
|
||||
Keyed.ul [] (List.map (\round -> ( round.call.id, finishedRound players round )) rounds)
|
||||
|
||||
Nothing ->
|
||||
Icon.spinner
|
||||
in
|
||||
div
|
||||
[ id "history" ]
|
||||
[ h1 [ class "mui--divider-bottom" ] [ text "Previous Rounds" ]
|
||||
, closeButton
|
||||
, content
|
||||
]
|
||||
|
||||
|
||||
finishedRound : List Player -> Round.FinishedRound -> Html msg
|
||||
finishedRound players round =
|
||||
let
|
||||
state =
|
||||
round.state
|
||||
|
||||
playedBy =
|
||||
state.playedByAndWinner.playedBy
|
||||
|
||||
winner =
|
||||
state.playedByAndWinner.winner
|
||||
|
||||
czar =
|
||||
Maybe.map
|
||||
(\player -> [ Icon.icon "gavel", text " ", text player.name ])
|
||||
(Player.byId round.czar players)
|
||||
|> Maybe.withDefault []
|
||||
|
||||
playedCardsByPlayer =
|
||||
Card.playedCardsByPlayer playedBy state.responses |> Dict.toList
|
||||
in
|
||||
li
|
||||
[ class "round" ]
|
||||
[ div [] [ div [ class "who" ] czar, CardsUI.call round.call [] ]
|
||||
, Keyed.ul [ class "plays" ] (List.map (\( id, cards ) -> ( toString id, responses players winner id cards )) playedCardsByPlayer)
|
||||
]
|
||||
|
||||
|
||||
responses : List Player -> Player.Id -> Player.Id -> List Card.Response -> Html msg
|
||||
responses players winnerId playerId responses =
|
||||
let
|
||||
winner =
|
||||
playerId == winnerId
|
||||
|
||||
winnerPrefix =
|
||||
if (winner) then
|
||||
[ Icon.icon "trophy", text " " ]
|
||||
else
|
||||
[]
|
||||
|
||||
player =
|
||||
Maybe.map
|
||||
(\player -> winnerPrefix ++ [ text player.name ])
|
||||
(Player.byId playerId players)
|
||||
|> Maybe.withDefault []
|
||||
in
|
||||
li []
|
||||
[ div [ class "responses" ]
|
||||
[ div [ classList [ ( "who", True ), ( "won", winner ) ] ] player
|
||||
, Keyed.ul [] (List.map (\r -> ( r.id, li [] [ CardsUI.response False [] r ] )) responses)
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
closeButton : Html ConsumerMessage
|
||||
closeButton =
|
||||
button
|
||||
[ class "mui-btn mui-btn--small mui-btn--fab"
|
||||
, onClick Close
|
||||
]
|
||||
[ Icon.icon "times" ]
|
||||
@@ -1,455 +0,0 @@
|
||||
module MassiveDecks.Scenes.Lobby exposing (update, view, init, subscriptions)
|
||||
|
||||
import String
|
||||
import Json.Encode exposing (encode)
|
||||
import Task
|
||||
import Time
|
||||
import WebSocket
|
||||
import AnimationFrame
|
||||
import Html exposing (Html)
|
||||
import MassiveDecks.Components.Errors as Errors
|
||||
import MassiveDecks.Components.QR as QR
|
||||
import MassiveDecks.Components.BrowserNotifications as BrowserNotifications
|
||||
import MassiveDecks.Components.Overlay as Overlay exposing (Overlay)
|
||||
import MassiveDecks.Components.TTS as TTS
|
||||
import MassiveDecks.Models exposing (Init)
|
||||
import MassiveDecks.Models.Event as Event exposing (Event)
|
||||
import MassiveDecks.Models.JSON.Encode exposing (encodePlayerSecret)
|
||||
import MassiveDecks.Models.Game as Game
|
||||
import MassiveDecks.Models.Game.Round as Round exposing (Round)
|
||||
import MassiveDecks.Models.Notification as Notification exposing (Notification)
|
||||
import MassiveDecks.Models.Player as Player exposing (Player)
|
||||
import MassiveDecks.Scenes.Playing as Playing
|
||||
import MassiveDecks.Scenes.Playing.Messages as Playing
|
||||
import MassiveDecks.Scenes.Config as Config
|
||||
import MassiveDecks.Scenes.Config.Messages as Config
|
||||
import MassiveDecks.Scenes.Lobby.UI as UI
|
||||
import MassiveDecks.Scenes.Lobby.Messages exposing (ConsumerMessage(..), Message(..))
|
||||
import MassiveDecks.Scenes.Lobby.Models exposing (Model)
|
||||
import MassiveDecks.Scenes.Lobby.Sidebar as Sidebar
|
||||
import MassiveDecks.Util as Util exposing ((:>))
|
||||
|
||||
|
||||
{-| Create the initial model for the lobby.
|
||||
-}
|
||||
init : Init -> Game.LobbyAndHand -> Player.Secret -> ( Model, Cmd ConsumerMessage )
|
||||
init init lobbyAndHand secret =
|
||||
let
|
||||
( config, configCmd ) =
|
||||
Config.init lobbyAndHand.lobby secret
|
||||
in
|
||||
{ lobby = lobbyAndHand.lobby
|
||||
, hand = lobbyAndHand.hand
|
||||
, config = config
|
||||
, playing = Playing.init init
|
||||
, browserNotifications = BrowserNotifications.init init.browserNotificationsSupported False
|
||||
, secret = secret
|
||||
, init = init
|
||||
, notification = Nothing
|
||||
, qrNeedsRendering = False
|
||||
, sidebar = Sidebar.init 768 {- Should match the enhance-width in less. -}
|
||||
, tts = TTS.init
|
||||
}
|
||||
! [ configCmd |> Cmd.map (ConfigMessage >> LocalMessage) ]
|
||||
|
||||
|
||||
{-| Subscriptions for the lobby.
|
||||
-}
|
||||
subscriptions : Model -> Sub ConsumerMessage
|
||||
subscriptions model =
|
||||
let
|
||||
delegated =
|
||||
case model.lobby.game of
|
||||
Game.Configuring ->
|
||||
Config.subscriptions model.config |> Sub.map ConfigMessage
|
||||
|
||||
Game.Playing round ->
|
||||
Playing.subscriptions model.playing |> Sub.map PlayingMessage
|
||||
|
||||
Game.Finished ->
|
||||
Sub.none
|
||||
|
||||
websocket =
|
||||
WebSocket.listen (webSocketUrl model.init.url model.lobby.gameCode) webSocketResponseDecoder
|
||||
|
||||
browserNotifications =
|
||||
BrowserNotifications.subscriptions model.browserNotifications |> Sub.map BrowserNotificationsMessage
|
||||
|
||||
render =
|
||||
if model.qrNeedsRendering then
|
||||
[ AnimationFrame.diffs (\_ -> LocalMessage RenderQr) ]
|
||||
else
|
||||
[]
|
||||
in
|
||||
Sub.batch
|
||||
([ delegated |> Sub.map LocalMessage
|
||||
, websocket
|
||||
, browserNotifications |> Sub.map LocalMessage
|
||||
]
|
||||
++ render
|
||||
)
|
||||
|
||||
|
||||
webSocketUrl : String -> String -> String
|
||||
webSocketUrl url gameCode =
|
||||
let
|
||||
( protocol, rest ) =
|
||||
case String.split ":" url of
|
||||
[] ->
|
||||
( "No protocol.", [] )
|
||||
|
||||
protocol :: rest ->
|
||||
( protocol, rest )
|
||||
|
||||
host =
|
||||
String.join ":" rest
|
||||
|
||||
baseUrl =
|
||||
case protocol of
|
||||
"http" ->
|
||||
"ws:" ++ host
|
||||
|
||||
"https" ->
|
||||
"wss:" ++ host
|
||||
|
||||
unknown ->
|
||||
let
|
||||
_ =
|
||||
Debug.log "Assuming https due to unknown protocol for URL" unknown
|
||||
in
|
||||
"wss:" ++ host
|
||||
in
|
||||
baseUrl ++ "api/lobbies/" ++ gameCode ++ "/notifications"
|
||||
|
||||
|
||||
webSocketResponseDecoder : String -> ConsumerMessage
|
||||
webSocketResponseDecoder response =
|
||||
if (response == "identify") then
|
||||
Identify |> LocalMessage
|
||||
else
|
||||
case Event.fromJson response of
|
||||
Ok event ->
|
||||
LocalMessage <| Batch <| handleEvent event
|
||||
|
||||
Err message ->
|
||||
ErrorMessage <| Errors.New ("Error handling notification: " ++ message) True
|
||||
|
||||
|
||||
{-| Render the lobby.
|
||||
-}
|
||||
view : Model -> Html ConsumerMessage
|
||||
view model =
|
||||
UI.view model
|
||||
|
||||
|
||||
{-| Handles messages and alters the model as appropriate.
|
||||
-}
|
||||
update : Message -> Model -> ( Model, Cmd ConsumerMessage )
|
||||
update message model =
|
||||
let
|
||||
lobby =
|
||||
model.lobby
|
||||
in
|
||||
case message of
|
||||
ConfigMessage configMessage ->
|
||||
case configMessage of
|
||||
Config.ErrorMessage errorMessage ->
|
||||
( model, ErrorMessage errorMessage |> Util.cmd )
|
||||
|
||||
Config.HandUpdate hand ->
|
||||
model |> updateLobbyAndHand (Game.LobbyAndHand model.lobby hand)
|
||||
|
||||
Config.LocalMessage localMessage ->
|
||||
let
|
||||
( config, cmd ) =
|
||||
Config.update localMessage model
|
||||
in
|
||||
( { model | config = config }, Cmd.map (LocalMessage << ConfigMessage) cmd )
|
||||
|
||||
PlayingMessage playingMessage ->
|
||||
case playingMessage of
|
||||
Playing.ErrorMessage errorMessage ->
|
||||
( model, ErrorMessage errorMessage |> Util.cmd )
|
||||
|
||||
Playing.HandUpdate hand ->
|
||||
model |> updateLobbyAndHand (Game.LobbyAndHand model.lobby hand)
|
||||
|
||||
Playing.TTSMessage ttsMessage ->
|
||||
( model, TTSMessage ttsMessage |> LocalMessage |> Util.cmd )
|
||||
|
||||
Playing.LocalMessage localMessage ->
|
||||
case model.lobby.game of
|
||||
Game.Playing round ->
|
||||
let
|
||||
( playing, cmd ) =
|
||||
Playing.update localMessage model round
|
||||
in
|
||||
( { model | playing = playing }, Cmd.map (LocalMessage << PlayingMessage) cmd )
|
||||
|
||||
_ ->
|
||||
( model, Cmd.none )
|
||||
|
||||
SidebarMessage sidebarMessage ->
|
||||
let
|
||||
( sidebarModel, cmd ) =
|
||||
Sidebar.update sidebarMessage model.sidebar
|
||||
in
|
||||
( { model | sidebar = sidebarModel }, Cmd.map (LocalMessage << SidebarMessage) cmd )
|
||||
|
||||
TTSMessage ttsMessage ->
|
||||
let
|
||||
( ttsModel, cmd ) =
|
||||
TTS.update ttsMessage model.tts
|
||||
in
|
||||
( { model | tts = ttsModel }, Cmd.map (LocalMessage << TTSMessage) cmd )
|
||||
|
||||
BrowserNotificationsMessage notificationMessage ->
|
||||
let
|
||||
( browserNotifications, localCmd, cmd ) =
|
||||
BrowserNotifications.update notificationMessage model.browserNotifications
|
||||
in
|
||||
{ model | browserNotifications = browserNotifications }
|
||||
! [ Cmd.map (LocalMessage << BrowserNotificationsMessage) localCmd
|
||||
, Cmd.map overlayAlert cmd
|
||||
]
|
||||
|
||||
BrowserNotificationForUser getUserId title iconName ->
|
||||
let
|
||||
cmd =
|
||||
case (getUserId lobby) of
|
||||
Just id ->
|
||||
if (id == model.secret.id) then
|
||||
Util.cmd (BrowserNotifications.notify { title = title, icon = icon model iconName } |> BrowserNotificationsMessage |> LocalMessage)
|
||||
else
|
||||
Cmd.none
|
||||
|
||||
Nothing ->
|
||||
Cmd.none
|
||||
in
|
||||
( model, cmd )
|
||||
|
||||
UpdateLobbyAndHand lobbyAndHand ->
|
||||
model |> updateLobbyAndHand lobbyAndHand
|
||||
|
||||
UpdateLobby update ->
|
||||
model |> updateLobbyAndHand (Game.LobbyAndHand (update lobby) model.hand)
|
||||
|
||||
UpdateHand hand ->
|
||||
model |> updateLobbyAndHand (Game.LobbyAndHand model.lobby hand)
|
||||
|
||||
SetNotification notification ->
|
||||
notificationChange model (notification lobby.players)
|
||||
|
||||
Identify ->
|
||||
( model, Cmd.map LocalMessage (WebSocket.send (webSocketUrl model.init.url model.lobby.gameCode) (encodePlayerSecret model.secret |> encode 0)) )
|
||||
|
||||
DisplayInviteOverlay ->
|
||||
{ model | qrNeedsRendering = True } ! [ Util.cmd (UI.inviteOverlay model.init.url model.lobby.gameCode |> OverlayMessage) ]
|
||||
|
||||
RenderQr ->
|
||||
{ model | qrNeedsRendering = False } ! [ QR.encodeAndRender "invite-qr-code" (Util.lobbyUrl model.init.url model.lobby.gameCode) ]
|
||||
|
||||
DismissNotification notification ->
|
||||
let
|
||||
newModel =
|
||||
if model.notification == Just notification then
|
||||
{ model | notification = Maybe.map Notification.hide model.notification }
|
||||
else
|
||||
model
|
||||
in
|
||||
( newModel, Cmd.none )
|
||||
|
||||
Batch messages ->
|
||||
model ! (List.map (Util.cmd << LocalMessage) messages)
|
||||
|
||||
NoOp ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleEvent : Event -> List Message
|
||||
handleEvent event =
|
||||
case event of
|
||||
Event.Sync lobbyAndHand ->
|
||||
[ UpdateLobbyAndHand lobbyAndHand ]
|
||||
|
||||
Event.PlayerJoin player ->
|
||||
[ SetNotification (Notification.playerJoin player.id)
|
||||
, UpdateLobby (\lobby -> { lobby | players = lobby.players ++ [ player ] })
|
||||
]
|
||||
|
||||
Event.PlayerStatus player status ->
|
||||
let
|
||||
browserNotification =
|
||||
case status of
|
||||
Player.NotPlayed ->
|
||||
[ BrowserNotificationForUser (\_ -> Just player) "You need to play a card for the round." "hourglass" ]
|
||||
|
||||
Player.Skipping ->
|
||||
[ BrowserNotificationForUser (\_ -> Just player) "You are being skipped due to inactivity." "fast-forward" ]
|
||||
|
||||
_ ->
|
||||
[]
|
||||
in
|
||||
[ updatePlayer player (\player -> { player | status = status })
|
||||
]
|
||||
++ browserNotification
|
||||
|
||||
Event.PlayerLeft player ->
|
||||
[ SetNotification (Notification.playerLeft player)
|
||||
, updatePlayer player (\player -> { player | left = True })
|
||||
]
|
||||
|
||||
Event.PlayerDisconnect player ->
|
||||
[ SetNotification (Notification.playerDisconnect player)
|
||||
, updatePlayer player (\player -> { player | disconnected = True })
|
||||
]
|
||||
|
||||
Event.PlayerReconnect player ->
|
||||
[ SetNotification (Notification.playerReconnect player)
|
||||
, updatePlayer player (\player -> { player | disconnected = False })
|
||||
]
|
||||
|
||||
Event.PlayerScoreChange player score ->
|
||||
[ updatePlayer player (\player -> { player | score = score }) ]
|
||||
|
||||
Event.HandChange hand ->
|
||||
[ UpdateHand hand ]
|
||||
|
||||
Event.RoundStart czar call ->
|
||||
[ UpdateLobby (\lobby -> { lobby | game = Game.Playing (Round czar call (Round.playing 0 False)) }) ]
|
||||
|
||||
Event.RoundPlayed playedCards ->
|
||||
[ updateRoundState (\state -> Round.playing playedCards (Round.afterTimeLimit state)) ]
|
||||
|
||||
Event.RoundJudging playedCards ->
|
||||
[ updateRoundState (\state -> Round.judging playedCards False)
|
||||
, BrowserNotificationForUser (\lobby -> lobbyRound lobby |> Maybe.map .czar) "You need to pick a winner for the round." "gavel"
|
||||
]
|
||||
|
||||
Event.RoundEnd finishedRound ->
|
||||
[ updateRoundState (\state -> Round.F finishedRound.state)
|
||||
, PlayingMessage <| Playing.LocalMessage <| Playing.FinishRound finishedRound
|
||||
]
|
||||
|
||||
Event.GameStart czar call ->
|
||||
[ UpdateLobby (\lobby -> { lobby | game = Game.Playing (Round czar call (Round.playing 0 False)) }) ]
|
||||
|
||||
Event.GameEnd ->
|
||||
[ UpdateLobby (\lobby -> { lobby | game = Game.Finished })
|
||||
, UpdateHand { hand = [] }
|
||||
]
|
||||
|
||||
Event.ConfigChange config ->
|
||||
[ UpdateLobby (\lobby -> { lobby | config = config }) ]
|
||||
|
||||
Event.RoundTimeLimitHit ->
|
||||
[ updateRound (\round -> Round.setAfterTimeLimit round True) ]
|
||||
|
||||
|
||||
lobbyRound : Game.Lobby -> Maybe Round
|
||||
lobbyRound lobby =
|
||||
case lobby.game of
|
||||
Game.Playing round ->
|
||||
Just round
|
||||
|
||||
_ ->
|
||||
Nothing
|
||||
|
||||
|
||||
updatePlayer : Player.Id -> (Player -> Player) -> Message
|
||||
updatePlayer playerId playerUpdate =
|
||||
UpdateLobby
|
||||
(\lobby ->
|
||||
{ lobby
|
||||
| players =
|
||||
List.map
|
||||
(\player ->
|
||||
if player.id == playerId then
|
||||
playerUpdate player
|
||||
else
|
||||
player
|
||||
)
|
||||
lobby.players
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
updateRound : (Round -> Round) -> Message
|
||||
updateRound roundUpdate =
|
||||
UpdateLobby
|
||||
(\lobby ->
|
||||
let
|
||||
game =
|
||||
case lobby.game of
|
||||
Game.Playing round ->
|
||||
Game.Playing (roundUpdate round)
|
||||
|
||||
other ->
|
||||
other
|
||||
in
|
||||
{ lobby | game = game }
|
||||
)
|
||||
|
||||
|
||||
updateRoundState : (Round.State -> Round.State) -> Message
|
||||
updateRoundState roundStateUpdate =
|
||||
updateRound (\round -> { round | state = roundStateUpdate round.state })
|
||||
|
||||
|
||||
overlayAlert : BrowserNotifications.ConsumerMessage -> ConsumerMessage
|
||||
overlayAlert message =
|
||||
case message of
|
||||
BrowserNotifications.PermissionChanged permission ->
|
||||
case permission of
|
||||
BrowserNotifications.Denied ->
|
||||
(Overlay.Show
|
||||
(Overlay "times-circle"
|
||||
"Can't enable desktop notifications."
|
||||
[ Html.text "You did not give Massive Decks permission to give you desktop notifications." ]
|
||||
)
|
||||
)
|
||||
|> OverlayMessage
|
||||
|
||||
_ ->
|
||||
NoOp |> LocalMessage
|
||||
|
||||
|
||||
icon : Model -> String -> Maybe String
|
||||
icon model name =
|
||||
Just (model.init.url ++ "assets/images/icons/" ++ name ++ ".png")
|
||||
|
||||
|
||||
type alias Update =
|
||||
Model -> ( Model, Cmd ConsumerMessage )
|
||||
|
||||
|
||||
updateLobbyAndHand : Game.LobbyAndHand -> Update
|
||||
updateLobbyAndHand lobbyAndHand model =
|
||||
{ model
|
||||
| lobby = lobbyAndHand.lobby
|
||||
, hand = lobbyAndHand.hand
|
||||
}
|
||||
! [ Util.cmd (Playing.LobbyAndHandUpdated |> Playing.LocalMessage |> PlayingMessage |> LocalMessage) ]
|
||||
|
||||
|
||||
{-| Handles a change to the displayed notification.
|
||||
-}
|
||||
notificationChange : Model -> Maybe Notification -> ( Model, Cmd ConsumerMessage )
|
||||
notificationChange model notification =
|
||||
let
|
||||
newNotification =
|
||||
notification |> Util.or model.notification
|
||||
|
||||
cmd =
|
||||
case newNotification of
|
||||
Just nn ->
|
||||
let
|
||||
dismiss =
|
||||
Util.after (Time.second * 5) (Task.succeed nn)
|
||||
in
|
||||
Task.perform (LocalMessage << DismissNotification) dismiss
|
||||
|
||||
Nothing ->
|
||||
Cmd.none
|
||||
in
|
||||
( { model | notification = newNotification }, cmd )
|
||||
@@ -1,43 +0,0 @@
|
||||
module MassiveDecks.Scenes.Lobby.Messages exposing (..)
|
||||
|
||||
import MassiveDecks.Models.Game as Game
|
||||
import MassiveDecks.Models.Card as Card
|
||||
import MassiveDecks.Models.Player as Player exposing (Player)
|
||||
import MassiveDecks.Models.Notification as Notification exposing (Notification)
|
||||
import MassiveDecks.Scenes.Lobby.Sidebar as Sidebar
|
||||
import MassiveDecks.Scenes.Config.Messages as Config
|
||||
import MassiveDecks.Scenes.Playing.Messages as Playing
|
||||
import MassiveDecks.Components.Errors as Errors
|
||||
import MassiveDecks.Components.Overlay as Overlay
|
||||
import MassiveDecks.Components.BrowserNotifications as BrowserNotifications
|
||||
import MassiveDecks.Components.TTS as TTS
|
||||
|
||||
|
||||
{-| This type is used for all sending of messages, allowing us to send messages handled outside this scene.
|
||||
-}
|
||||
type ConsumerMessage
|
||||
= ErrorMessage Errors.Message
|
||||
| OverlayMessage (Overlay.Message Message)
|
||||
| Leave
|
||||
| LocalMessage Message
|
||||
|
||||
|
||||
{-| The messages used in the start screen.
|
||||
-}
|
||||
type Message
|
||||
= DismissNotification Notification
|
||||
| SetNotification (List Player -> Maybe Notification)
|
||||
| UpdateLobbyAndHand Game.LobbyAndHand
|
||||
| UpdateLobby (Game.Lobby -> Game.Lobby)
|
||||
| UpdateHand Card.Hand
|
||||
| Identify
|
||||
| DisplayInviteOverlay
|
||||
| BrowserNotificationForUser (Game.Lobby -> Maybe Player.Id) String String
|
||||
| RenderQr
|
||||
| Batch (List Message)
|
||||
| NoOp
|
||||
| BrowserNotificationsMessage BrowserNotifications.Message
|
||||
| ConfigMessage Config.ConsumerMessage
|
||||
| PlayingMessage Playing.ConsumerMessage
|
||||
| SidebarMessage Sidebar.Message
|
||||
| TTSMessage TTS.Message
|
||||
@@ -1,29 +0,0 @@
|
||||
module MassiveDecks.Scenes.Lobby.Models exposing (Model)
|
||||
|
||||
import MassiveDecks.Models exposing (Init)
|
||||
import MassiveDecks.Models.Game as Game
|
||||
import MassiveDecks.Models.Player as Player
|
||||
import MassiveDecks.Models.Card as Card
|
||||
import MassiveDecks.Models.Notification as Notification exposing (Notification)
|
||||
import MassiveDecks.Scenes.Lobby.Sidebar as Sidebar
|
||||
import MassiveDecks.Scenes.Config.Models as Config
|
||||
import MassiveDecks.Scenes.Playing.Models as Playing
|
||||
import MassiveDecks.Components.BrowserNotifications as BrowserNotifications
|
||||
import MassiveDecks.Components.TTS as TTS
|
||||
|
||||
|
||||
{-| The state of the lobby.
|
||||
-}
|
||||
type alias Model =
|
||||
{ lobby : Game.Lobby
|
||||
, hand : Card.Hand
|
||||
, config : Config.Model
|
||||
, playing : Playing.Model
|
||||
, browserNotifications : BrowserNotifications.Model
|
||||
, secret : Player.Secret
|
||||
, init : Init
|
||||
, notification : Maybe Notification
|
||||
, qrNeedsRendering : Bool
|
||||
, sidebar : Sidebar.Model
|
||||
, tts : TTS.Model
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
port module MassiveDecks.Scenes.Lobby.Sidebar exposing (Model, Message(..), init, update)
|
||||
|
||||
import Task
|
||||
import Window
|
||||
import MassiveDecks.Util as Util
|
||||
|
||||
|
||||
{-| This component handles the players sidebar for the lobby view. On a larger display, this bar is shown, but can be
|
||||
hidden by the user. On small displays, it is hidden but can be popped out as a modal window over the top of the
|
||||
lobby.
|
||||
|
||||
No rendering is done within this module - instead the model should be inspected and the sidebar rendered as required.
|
||||
-}
|
||||
type alias Model =
|
||||
{ enhanceWidth : Int
|
||||
, hidden : Bool
|
||||
, shownAsOverlay : Bool
|
||||
}
|
||||
|
||||
|
||||
type Message
|
||||
= Toggle
|
||||
| Show Int
|
||||
| Hide
|
||||
|
||||
|
||||
init : Int -> Model
|
||||
init enhanceWidth =
|
||||
{ enhanceWidth = enhanceWidth
|
||||
, hidden = False
|
||||
, shownAsOverlay = False
|
||||
}
|
||||
|
||||
|
||||
update : Message -> Model -> ( Model, Cmd Message )
|
||||
update message model =
|
||||
case message of
|
||||
Toggle ->
|
||||
( model, Task.perform Show Window.width )
|
||||
|
||||
Show screenWidth ->
|
||||
if model.shownAsOverlay then
|
||||
( { model | shownAsOverlay = False }, Cmd.none )
|
||||
else if screenWidth > model.enhanceWidth then
|
||||
( { model | hidden = not model.hidden }, Cmd.none )
|
||||
else
|
||||
( { model | shownAsOverlay = True }, Cmd.none )
|
||||
|
||||
Hide ->
|
||||
( { model | shownAsOverlay = False }, Cmd.none )
|
||||
@@ -1,431 +0,0 @@
|
||||
module MassiveDecks.Scenes.Lobby.UI exposing (view, inviteOverlay)
|
||||
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import MassiveDecks.Components.About as About
|
||||
import MassiveDecks.Components.Errors as Errors
|
||||
import MassiveDecks.Components.QR as QR
|
||||
import MassiveDecks.Components.Icon as Icon
|
||||
import MassiveDecks.Components.Overlay as Overlay
|
||||
import MassiveDecks.Components.BrowserNotifications as BrowserNotifications
|
||||
import MassiveDecks.Components.TTS as TTS
|
||||
import MassiveDecks.Scenes.Config as Config
|
||||
import MassiveDecks.Scenes.Playing as Playing
|
||||
import MassiveDecks.Scenes.Lobby.Models exposing (Model)
|
||||
import MassiveDecks.Scenes.Lobby.Messages exposing (ConsumerMessage(..), Message(..))
|
||||
import MassiveDecks.Scenes.Lobby.Sidebar as Sidebar
|
||||
import MassiveDecks.Models.Player as Player exposing (Player, Status(..))
|
||||
import MassiveDecks.Models.Game as Game
|
||||
import MassiveDecks.Models.Notification as Notification exposing (Notification)
|
||||
import MassiveDecks.Util as Util
|
||||
|
||||
|
||||
view : Model -> Html ConsumerMessage
|
||||
view model =
|
||||
let
|
||||
lobby =
|
||||
model.lobby
|
||||
|
||||
url =
|
||||
model.init.url
|
||||
|
||||
gameCode =
|
||||
lobby.gameCode
|
||||
|
||||
players =
|
||||
lobby.players
|
||||
|
||||
( header, contents ) =
|
||||
case lobby.game of
|
||||
Game.Configuring ->
|
||||
( [], [ Config.view model |> Html.map (ConfigMessage >> LocalMessage) ] )
|
||||
|
||||
Game.Playing round ->
|
||||
let
|
||||
( h, c ) =
|
||||
Playing.view model round
|
||||
in
|
||||
( h |> List.map (Html.map (PlayingMessage >> LocalMessage)), c |> List.map (Html.map (PlayingMessage >> LocalMessage)) )
|
||||
|
||||
Game.Finished ->
|
||||
( [], [] )
|
||||
in
|
||||
root model.sidebar.hidden
|
||||
[ appHeader header model
|
||||
, spacer
|
||||
, scores model.secret.id model.lobby.owner model.sidebar.shownAsOverlay players
|
||||
, contentWrapper contents
|
||||
]
|
||||
|
||||
|
||||
root : Bool -> List (Html msg) -> Html msg
|
||||
root hideScores contents =
|
||||
div [ classList [ ( "content", True ), ( "hide-scores", hideScores ) ] ] contents
|
||||
|
||||
|
||||
contentWrapper : List (Html msg) -> Html msg
|
||||
contentWrapper contents =
|
||||
div [ id "content-wrapper" ] contents
|
||||
|
||||
|
||||
spacer : Html msg
|
||||
spacer =
|
||||
div [ class "mui--appbar-height" ] []
|
||||
|
||||
|
||||
scores : Player.Id -> Player.Id -> Bool -> List Player -> Html ConsumerMessage
|
||||
scores client owner shownAsOverlay players =
|
||||
let
|
||||
hideMessage =
|
||||
LocalMessage (SidebarMessage Sidebar.Hide)
|
||||
|
||||
closeLink =
|
||||
if shownAsOverlay then
|
||||
[ a
|
||||
[ class "link close-link"
|
||||
, title "Hide."
|
||||
, attribute "tabindex" "0"
|
||||
, attribute "role" "button"
|
||||
, onClick hideMessage
|
||||
]
|
||||
[ Icon.icon "times" ]
|
||||
]
|
||||
else
|
||||
[]
|
||||
|
||||
sidebar =
|
||||
div [ id "scores", classList [ ( "shownAsOverlay", shownAsOverlay ), ( "mui--z1", True ) ] ]
|
||||
[ div [ id "scores-title", class "mui--appbar-line-height mui--text-headline" ] ([ text "Players" ] ++ closeLink)
|
||||
, div [ class "mui-divider" ] []
|
||||
, table [ class "mui-table" ]
|
||||
[ thead []
|
||||
[ tr []
|
||||
[ th [ class "state", title "State" ] [ Icon.icon "tasks" ]
|
||||
, th [ class "name" ] [ text "Player" ]
|
||||
, th [ class "score", title "Score" ] [ Icon.icon "trophy" ]
|
||||
]
|
||||
]
|
||||
, tbody [] (List.map (score client owner) players)
|
||||
]
|
||||
]
|
||||
in
|
||||
if shownAsOverlay then
|
||||
div
|
||||
[ id "mui-overlay"
|
||||
, Util.onClickIfId "mui-overlay" hideMessage (LocalMessage NoOp)
|
||||
, Util.onKeyDown "Escape" hideMessage (LocalMessage NoOp)
|
||||
, tabindex 0
|
||||
]
|
||||
[ sidebar ]
|
||||
else
|
||||
sidebar
|
||||
|
||||
|
||||
score : Player.Id -> Player.Id -> Player -> Html msg
|
||||
score client owner player =
|
||||
let
|
||||
classes =
|
||||
classList
|
||||
[ ( Player.statusName player.status, True )
|
||||
, ( "disconnected", player.disconnected )
|
||||
, ( "left", player.left )
|
||||
, ( "you", player.id == client )
|
||||
]
|
||||
|
||||
prename =
|
||||
List.concat
|
||||
[ if player.id == owner then
|
||||
[ Icon.icon "star", text " " ]
|
||||
else
|
||||
[]
|
||||
, if player.id == client then
|
||||
[ Icon.icon "user-circle", text " " ]
|
||||
else
|
||||
[]
|
||||
, if player.disconnected then
|
||||
[ Icon.icon "minus-circle", text " " ]
|
||||
else
|
||||
[]
|
||||
]
|
||||
|
||||
afterNameTitle =
|
||||
(if player.id == owner then
|
||||
" (Owner)"
|
||||
else
|
||||
""
|
||||
)
|
||||
++ (if player.id == client then
|
||||
" (You)"
|
||||
else
|
||||
""
|
||||
)
|
||||
++ (if player.disconnected then
|
||||
" (Disconnected)"
|
||||
else
|
||||
""
|
||||
)
|
||||
in
|
||||
tr [ classes ]
|
||||
[ td [ class "state", title (statusDescription player) ] [ (playerIcon player) ]
|
||||
, td [ class "name", title (player.name ++ afterNameTitle) ] (List.append prename [ text player.name ])
|
||||
, td [ class "score" ] [ text (toString player.score) ]
|
||||
]
|
||||
|
||||
|
||||
appHeader : List (Html ConsumerMessage) -> Model -> Html ConsumerMessage
|
||||
appHeader contents model =
|
||||
(header []
|
||||
[ div [ class "mui-appbar mui--z1 mui--appbar-line-height" ]
|
||||
[ div [ class "mui--appbar-line-height" ]
|
||||
[ span [ class "score-buttons" ] ([ scoresButton ] ++ (notificationPopup model.notification))
|
||||
, span [ id "title", class "mui--text-title mui--visible-xs-inline-block" ] contents
|
||||
, gameMenu model
|
||||
]
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
scoresButton : Html ConsumerMessage
|
||||
scoresButton =
|
||||
button
|
||||
[ class ("scores-toggle mui-btn mui-btn--small mui-btn--primary badged")
|
||||
, title "Players."
|
||||
, onClick (LocalMessage (SidebarMessage Sidebar.Toggle))
|
||||
]
|
||||
[ Icon.fwIcon "users" ]
|
||||
|
||||
|
||||
notificationPopup : Maybe Notification -> List (Html ConsumerMessage)
|
||||
notificationPopup notification =
|
||||
case notification of
|
||||
Just notification ->
|
||||
let
|
||||
hidden =
|
||||
if notification.visible then
|
||||
""
|
||||
else
|
||||
"hide"
|
||||
in
|
||||
[ div
|
||||
[ class ("badge mui--z2 " ++ hidden)
|
||||
, title notification.description
|
||||
, onClick (LocalMessage (DismissNotification notification))
|
||||
]
|
||||
[ Icon.icon notification.icon, text (" " ++ notification.name) ]
|
||||
]
|
||||
|
||||
Nothing ->
|
||||
[ div [ class ("badge mui--z2 hide") ] [] ]
|
||||
|
||||
|
||||
statusDescription : Player -> String
|
||||
statusDescription player =
|
||||
case player.status of
|
||||
NotPlayed ->
|
||||
"Choosing"
|
||||
|
||||
Played ->
|
||||
"Played"
|
||||
|
||||
Czar ->
|
||||
"Round Czar"
|
||||
|
||||
Ai ->
|
||||
"A Computer"
|
||||
|
||||
Neutral ->
|
||||
""
|
||||
|
||||
Skipping ->
|
||||
"Being Skipped"
|
||||
|
||||
|
||||
statusIcon : Status -> Html msg
|
||||
statusIcon status =
|
||||
Maybe.map Icon.fwIcon (statusIconName status) |> Maybe.withDefault (text "")
|
||||
|
||||
|
||||
statusIconName : Status -> Maybe String
|
||||
statusIconName status =
|
||||
case status of
|
||||
NotPlayed ->
|
||||
Just "hourglass"
|
||||
|
||||
Played ->
|
||||
Just "check"
|
||||
|
||||
Czar ->
|
||||
Just "gavel"
|
||||
|
||||
Ai ->
|
||||
Just "cogs"
|
||||
|
||||
Neutral ->
|
||||
Nothing
|
||||
|
||||
Skipping ->
|
||||
Just "fast-forward"
|
||||
|
||||
|
||||
playerIcon : Player -> Html msg
|
||||
playerIcon player =
|
||||
if player.left then
|
||||
Icon.icon "sign-out"
|
||||
else
|
||||
statusIcon player.status
|
||||
|
||||
|
||||
{-| The overlay for inviting players to a lobby.
|
||||
-}
|
||||
inviteOverlay : String -> String -> Overlay.Message msg
|
||||
inviteOverlay appUrl gameCode =
|
||||
let
|
||||
url =
|
||||
Util.lobbyUrl appUrl gameCode
|
||||
|
||||
contents =
|
||||
[ p [] [ text "To invite other players, simply send them this link: " ]
|
||||
, p [] [ a [ href url ] [ text url ] ]
|
||||
, p [] [ text "Have them scan this QR code: " ]
|
||||
, QR.view "invite-qr-code"
|
||||
, p [] [ text "Or give them this game code to enter on the start page: " ]
|
||||
, p [] [ input [ readonly True, value gameCode ] [] ]
|
||||
]
|
||||
in
|
||||
Overlay.Show
|
||||
{ icon = "bullhorn"
|
||||
, title = "Invite Players"
|
||||
, contents = contents
|
||||
}
|
||||
|
||||
|
||||
notificationsMenuItem : BrowserNotifications.Model -> List (Html ConsumerMessage)
|
||||
notificationsMenuItem model =
|
||||
let
|
||||
( notClickable, enabled ) =
|
||||
if not model.supported then
|
||||
( Just "Your browser does not support desktop notifications.", False )
|
||||
else if model.permission == Just BrowserNotifications.Denied then
|
||||
( Just "You have denied Massive Decks permission to display desktop notifications.", False )
|
||||
else
|
||||
( Nothing, model.enabled )
|
||||
|
||||
classes =
|
||||
classList
|
||||
[ ( "link", True )
|
||||
, ( "disabled", not (Util.isNothing notClickable) )
|
||||
]
|
||||
|
||||
extraAttrs =
|
||||
case notClickable of
|
||||
Nothing ->
|
||||
[ onClick
|
||||
(LocalMessage <|
|
||||
BrowserNotificationsMessage <|
|
||||
(if enabled then
|
||||
BrowserNotifications.disable
|
||||
else
|
||||
BrowserNotifications.enable
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
Just msg ->
|
||||
[ title msg ]
|
||||
|
||||
attributes =
|
||||
[ classes, attribute "tabindex" "0", attribute "role" "button" ] ++ extraAttrs
|
||||
|
||||
description =
|
||||
" "
|
||||
++ (if enabled then
|
||||
"Disable"
|
||||
else
|
||||
"Enable"
|
||||
)
|
||||
++ " Notifications"
|
||||
in
|
||||
[ li []
|
||||
[ a attributes
|
||||
[ Icon.fwIcon
|
||||
(if enabled then
|
||||
"bell-slash"
|
||||
else
|
||||
"bell"
|
||||
)
|
||||
, text description
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
{-| The menu for the game.
|
||||
-}
|
||||
gameMenu : Model -> Html ConsumerMessage
|
||||
gameMenu model =
|
||||
let
|
||||
url =
|
||||
(Errors.reportUrl { url = model.init.url, version = model.init.version }
|
||||
"I was [a short explanation of what you were doing] when [a short explanation of the bug]."
|
||||
)
|
||||
in
|
||||
div [ class "menu mui-dropdown" ]
|
||||
[ button
|
||||
[ class "mui-btn mui-btn--small mui-btn--primary"
|
||||
, attribute "data-mui-toggle" "dropdown"
|
||||
, title "Game menu."
|
||||
]
|
||||
[ Icon.fwIcon "ellipsis-h" ]
|
||||
, ul [ class "mui-dropdown__menu mui-dropdown__menu--right" ]
|
||||
([ li []
|
||||
[ a
|
||||
[ class "link"
|
||||
, attribute "tabindex" "0"
|
||||
, attribute "role" "button"
|
||||
, onClick (DisplayInviteOverlay |> LocalMessage)
|
||||
]
|
||||
[ Icon.fwIcon "bullhorn", text " Invite Players" ]
|
||||
]
|
||||
]
|
||||
++ (notificationsMenuItem model.browserNotifications)
|
||||
++ [ li []
|
||||
[ a
|
||||
[ class "link"
|
||||
, attribute "tabindex" "0"
|
||||
, attribute "role" "button"
|
||||
, onClick (TTS.Enabled (not model.tts.enabled) |> TTSMessage |> LocalMessage)
|
||||
]
|
||||
(if model.tts.enabled then
|
||||
[ Icon.fwIcon "volume-off", text " Disable Speech" ]
|
||||
else
|
||||
[ Icon.fwIcon "volume-up", text " Enable Speech" ]
|
||||
)
|
||||
]
|
||||
, li []
|
||||
[ a
|
||||
[ class "link"
|
||||
, attribute "tabindex" "0"
|
||||
, attribute "role" "button"
|
||||
, onClick Leave
|
||||
]
|
||||
[ Icon.fwIcon "sign-out", text " Leave Game" ]
|
||||
]
|
||||
, li [ class "mui-divider" ] []
|
||||
, li []
|
||||
[ a
|
||||
[ class "link"
|
||||
, attribute "tabindex" "0"
|
||||
, attribute "role" "button"
|
||||
, onClick (About.show model.init.version |> OverlayMessage)
|
||||
]
|
||||
[ Icon.fwIcon "info-circle", text " About" ]
|
||||
]
|
||||
, li []
|
||||
[ a [ href url, target "_blank", rel "noopener" ]
|
||||
[ Icon.fwIcon "bug", text " Report a bug" ]
|
||||
]
|
||||
]
|
||||
)
|
||||
]
|
||||