Initial v2

This commit is contained in:
Gareth Latty
2019-06-22 00:30:26 +01:00
parent 9b74f7ed4a
commit dcdf011304
379 changed files with 36123 additions and 10481 deletions
+7 -28
View File
@@ -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/
+27 -5
View File
@@ -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
+36 -34
View File
@@ -1,40 +1,42 @@
# Massive Decks [![Build Status](https://travis-ci.org/Lattyware/massivedecks.svg?branch=master)](https://travis-ci.org/Lattyware/massivedecks)
# Massive Decks [![Build Status](https://travis-ci.org/Lattyware/massivedecks.svg?branch=v2)](https://travis-ci.org/Lattyware/massivedecks)
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](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.)
[![A screenshot of the game.](https://cloud.githubusercontent.com/assets/1239492/16138236/8299ee32-3433-11e6-8ca2-36993bb83d58.png)][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/
-41
View File
@@ -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"
}
]
}
-63
View File
@@ -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;
}
}
}
-165
View File
@@ -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;
}
-533
View File
@@ -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;
}
}
-183
View File
@@ -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;
}
-143
View File
@@ -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;
}
}
-13
View File
@@ -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;
-43
View File
@@ -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("")
}
}
-13
View File
@@ -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])
}
}
-20
View File
@@ -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))
}
-141
View File
@@ -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())
}
}
-46
View File
@@ -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)
}
}
}
-35
View File
@@ -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)
}
}
-100
View File
@@ -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)
}
-312
View File
@@ -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
}
-416
View File
@@ -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())
}
}
-280
View File
@@ -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"
)
}
-69
View File
@@ -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
}
-118
View File
@@ -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
}
}
-24
View File
@@ -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]
}
}
-53
View File
@@ -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)
}
-99
View File
@@ -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
}
-43
View File
@@ -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
}
-47
View File
@@ -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>
-23
View File
@@ -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">
-20
View File
@@ -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>
-23
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
.idea/
dist/
elm-stuff/
node_modules/
+31
View File
@@ -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"]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -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

Binary file not shown.

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

+6
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

+4
View File
@@ -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

+4
View File
@@ -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

+36
View File
@@ -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": {}
}
}
+54
View File
@@ -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";
}
}
}
+11086
View File
File diff suppressed because it is too large Load Diff
+59
View File
@@ -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"
}
}
-25
View File
@@ -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
-299
View File
@@ -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
-202
View File
@@ -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 ->
[]
-17
View File
@@ -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
-28
View File
@@ -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)
}
-71
View File
@@ -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
-94
View File
@@ -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.")
-64
View File
@@ -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)
-106
View File
@@ -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
-164
View File
@@ -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" ]
-455
View File
@@ -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 )
-431
View File
@@ -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" ]
]
]
)
]

Some files were not shown because too many files have changed in this diff Show More