Compare commits

..

16 commits

Author SHA1 Message Date
9969561af1 Add Lobby (#43)
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2019-10-16 08:31:03 +00:00
2f6e6e97ca
Disable versioning build if not building it
All checks were successful
continuous-integration/drone/push Build is passing
2019-09-21 11:58:05 +02:00
d093199cd9 Don't build versioned on master (#42)
All checks were successful
continuous-integration/drone/push Build is passing
2019-09-21 09:37:09 +00:00
83b6f8f188 Allow a subset of console.* statements in production (#41)
All checks were successful
continuous-integration/drone/push Build is passing
2019-09-21 09:19:16 +00:00
9ce4dd67f5 Various bugfixes (#40)
All checks were successful
continuous-integration/drone/push Build is passing
- Fix download state not being synced correctly
- Fix double-flicker by only loading the placeholder if needed
- Fix fractional part of download numbers so they flicker less

Closes #38
2019-09-21 08:54:21 +00:00
70fe698c22 Update README and fix linting issues (#37)
All checks were successful
continuous-integration/drone/push Build is passing
Solves #16
2019-09-20 14:41:24 +00:00
7156fe23e5 Fix "player-left" event not being sent (#29)
All checks were successful
continuous-integration/drone/push Build is passing
2019-09-20 10:59:15 +00:00
a73828fc86 Create settings page and image cache (#27)
Some checks failed
continuous-integration/drone/push Build is failing
2019-09-20 10:49:15 +00:00
29ab978612 Add deck builder button and version tagging (#28)
All checks were successful
continuous-integration/drone/push Build is passing
2019-09-18 14:22:23 +00:00
87c0a69cc2 Integrate draft/network stack via Vuex (#26)
All checks were successful
continuous-integration/drone/push Build is passing
2019-09-17 13:22:43 +00:00
412bb56b32 Add top navigation bar (#25)
All checks were successful
continuous-integration/drone/push Build is passing
2019-09-16 21:05:19 +00:00
bc04fdbadb Add home page (#24)
All checks were successful
continuous-integration/drone/push Build is passing
2019-09-16 20:31:05 +00:00
55fad9db70 Basic draft library (#19)
All checks were successful
continuous-integration/drone/push Build is passing
Supports set, cube, i8pcube drafts with dumb bots. No support for block draft yet.
2019-09-16 13:53:07 +00:00
77f146625c Add basic draft screen (#20)
All checks were successful
continuous-integration/drone/push Build is passing
Full of mock code right now, needs to be removed when the real code will settle in
2019-09-15 11:39:04 +00:00
eade73f9f5 Add basic deck builder (#12)
All checks were successful
continuous-integration/drone/push Build is passing
Includes:

- Basic layout
- Card list
- Filter cards by set/color
- Filter cards by type
- Filter cards by rule text
- Add/remove cards to decklist
- Export deck to ponyhead URL
2019-09-12 09:11:32 +00:00
e14f6679fb Update build to allow custom path (or use relative paths) (#17)
All checks were successful
continuous-integration/drone/push Build is passing
2019-09-10 10:20:31 +00:00
103 changed files with 4283 additions and 274 deletions

View file

@ -1,3 +1,4 @@
---
kind: pipeline kind: pipeline
name: default name: default
@ -14,11 +15,19 @@ steps:
- name: dependencies - name: dependencies
image: node image: node
failure: ignore
commands: commands:
- yarn - yarn
depends_on: depends_on:
- restore-cache - restore-cache
- name: lint
image: node
commands:
- yarn lint
depends_on:
- dependencies
- name: build_versioned - name: build_versioned
image: node image: node
commands: commands:
@ -28,6 +37,9 @@ steps:
when: when:
event: event:
- push - push
branch:
exclude:
- master
depends_on: depends_on:
- dependencies - dependencies
@ -72,6 +84,9 @@ steps:
when: when:
event: event:
- push - push
branch:
exclude:
- master
depends_on: depends_on:
- build_versioned - build_versioned
@ -116,16 +131,16 @@ steps:
- name: test - name: test
image: node image: node
commands: commands:
- yarn test:unit - yarn test:unit --runInBand
depends_on: depends_on:
- dependencies - dependencies
- name: coverage - name: coverage
image: node image: node
commands: commands:
- yarn test:unit --coverage - yarn test:unit --coverage --runInBand
depends_on: depends_on:
- dependencies - test # Must run after test otherwise SQLite will get mad
- name: upload_coverage - name: upload_coverage
image: plugins/s3 image: plugins/s3
@ -165,6 +180,7 @@ steps:
- name: rebuild-cache - name: rebuild-cache
image: drillster/drone-volume-cache image: drillster/drone-volume-cache
failure: ignore
volumes: volumes:
- name: cache - name: cache
path: /cache path: /cache
@ -174,3 +190,13 @@ steps:
- ./node_modules - ./node_modules
depends_on: depends_on:
- dependencies - dependencies
volumes:
- name: cache
host:
path: /opt/gitea/drone-cache/mcg/mlpcardgame
---
kind: signature
hmac: 73d743702e8545bf469f17b96c289a5d4f1d56eb3f1966c7c0a0e38d44d38aba
...

View file

@ -8,7 +8,10 @@ module.exports = {
extends: ["plugin:vue/essential", "@vue/prettier", "@vue/typescript"], extends: ["plugin:vue/essential", "@vue/prettier", "@vue/typescript"],
rules: { rules: {
"no-console": process.env.NODE_ENV === "production" ? "error" : "off", "no-console":
process.env.NODE_ENV === "production"
? ["error", { allow: ["info", "warn", "error"] }]
: "off",
"no-debugger": process.env.NODE_ENV === "production" ? "error" : "off" "no-debugger": process.env.NODE_ENV === "production" ? "error" : "off"
}, },
@ -18,9 +21,7 @@ module.exports = {
overrides: [ overrides: [
{ {
files: [ files: ["**/__tests__/*.{j,t}s?(x)"],
'**/__tests__/*.{j,t}s?(x)'
],
env: { env: {
jest: true jest: true
} }

1
.gitignore vendored
View file

@ -2,6 +2,7 @@
node_modules node_modules
/dist /dist
coverage coverage
*.sqlite
# local env files # local env files
.env.local .env.local

View file

@ -4,6 +4,58 @@
Work in progress name, work in progress game Work in progress name, work in progress game
Try the latest build here: [mcg-builds.zyg.ovh/latest](https://mcg-builds.zyg.ovh/latest/)
## Development
### Dependencies
Fetch dependencies with `yarn`:
```sh
yarn --dev
```
### Run local server for development
Run this command:
```sh
yarn serve
```
then visit [localhost:8080](http://localhost:8080) (URL might be different if something is already listening on port 8080)
### Run tests
Run unit tests with Jest:
```sh
yarn test:unit
```
Generate a coverage profile with:
```sh
yarn test:unit --coverage
```
### Lint code
Before you submit a PR, make sure the code is formatted correctly with:
```sh
yarn lint
```
## Building for release
### Build
```sh
yarn build
```
## License ## License
Code is ISC, Assets "depends", some stuff is taken from FreeSound and DeviantArt so your best bet is to just praise the copyright gods. Code is ISC, Assets "depends", some stuff is taken from FreeSound and DeviantArt so your best bet is to just praise the copyright gods.

View file

@ -2,7 +2,7 @@ module.exports = {
moduleFileExtensions: ["js", "jsx", "json", "vue", "ts", "tsx"], moduleFileExtensions: ["js", "jsx", "json", "vue", "ts", "tsx"],
transform: { transform: {
"^.+\\.vue$": "vue-jest", "^.+\\.vue$": "vue-jest",
".+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$": ".+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2|webp)$":
"jest-transform-stub", "jest-transform-stub",
"^.+\\.tsx?$": "ts-jest" "^.+\\.tsx?$": "ts-jest"
}, },

View file

@ -9,18 +9,29 @@
"test:unit": "vue-cli-service test:unit" "test:unit": "vue-cli-service test:unit"
}, },
"dependencies": { "dependencies": {
"@types/jszip": "^3.1.6",
"axios": "^0.18.0",
"babel-core": "7.0.0-bridge.0",
"babel-eslint": "^10.0.1",
"buefy": "^0.8.2", "buefy": "^0.8.2",
"bulma-prefers-dark": "^0.1.0-beta.0",
"core-js": "^2.6.5", "core-js": "^2.6.5",
"dexie": "^2.0.4", "dexie": "^2.0.4",
"eventemitter3": "^4.0.0", "eventemitter3": "^4.0.0",
"jszip": "^3.2.2",
"node-sass": "^4.9.0",
"peerjs": "^1.0.4", "peerjs": "^1.0.4",
"register-service-worker": "^1.6.2", "register-service-worker": "^1.6.2",
"sass": "^1.18.0",
"sass-loader": "^7.1.0",
"typescript": "^3.4.3",
"vue": "^2.6.10", "vue": "^2.6.10",
"vue-class-component": "^7.0.2", "vue-class-component": "^7.0.2",
"vue-property-decorator": "^8.1.0", "vue-property-decorator": "^8.1.0",
"vue-router": "^3.0.3", "vue-router": "^3.0.3",
"vuex": "^3.0.1", "vuex": "^3.0.1",
"vuex-class": "^0.3.2" "vuex-class": "^0.3.2",
"worker-loader": "^2.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^23.1.4", "@types/jest": "^23.1.4",
@ -33,18 +44,16 @@
"@vue/eslint-config-prettier": "^5.0.0", "@vue/eslint-config-prettier": "^5.0.0",
"@vue/eslint-config-typescript": "^4.0.0", "@vue/eslint-config-typescript": "^4.0.0",
"@vue/test-utils": "^1.0.0-beta.29", "@vue/test-utils": "^1.0.0-beta.29",
"babel-core": "7.0.0-bridge.0",
"babel-eslint": "^10.0.1",
"eslint": "^5.16.0", "eslint": "^5.16.0",
"eslint-plugin-prettier": "^3.1.0", "eslint-plugin-prettier": "^3.1.0",
"eslint-plugin-vue": "^5.0.0", "eslint-plugin-vue": "^5.0.0",
"node-sass": "^4.9.0", "git-describe": "^4.0.4",
"indexeddbshim": "^4.1.0",
"prettier": "^1.18.2", "prettier": "^1.18.2",
"sass": "^1.18.0",
"sass-loader": "^7.1.0",
"ts-jest": "^23.0.0", "ts-jest": "^23.0.0",
"typescript": "^3.4.3", "vue-cli-plugin-axios": "^0.0.4",
"vue-cli-plugin-buefy": "^0.3.7", "vue-cli-plugin-buefy": "^0.3.7",
"vue-cli-plugin-git-describe": "^1.0.0",
"vue-template-compiler": "^2.6.10" "vue-template-compiler": "^2.6.10"
}, },
"license": "ISC" "license": "ISC"

View file

@ -1,18 +1,22 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0"> <meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico"> <link rel="icon" href="<%= BASE_URL %>favicon.ico">
<link rel="stylesheet" href="//cdn.materialdesignicons.com/2.0.46/css/materialdesignicons.min.css"> <link rel="stylesheet" href="//cdn.materialdesignicons.com/2.0.46/css/materialdesignicons.min.css">
<title>mcgvue</title> <title>MLPCARDGAME</title>
</head> </head>
<body> <body>
<noscript> <noscript>
<strong>We're sorry but mcgvue doesn't work properly without JavaScript enabled. Please enable it to continue.</strong> <strong>We're sorry but mcgvue doesn't work properly without JavaScript enabled. Please enable it to
continue.</strong>
</noscript> </noscript>
<div id="app"></div> <div id="app"></div>
<!-- built files will be auto injected --> <!-- built files will be auto injected -->
</body> </body>
</html> </html>

View file

@ -1,14 +1,14 @@
{ {
"name": "mcgvue", "name": "MLPCARDGAME",
"short_name": "mcgvue", "short_name": "mcg",
"icons": [ "description": "MLP:CCG simulator",
{ "icons": [{
"src": "./img/icons/android-chrome-192x192.png", "src": "./images/icons/android-chrome-192x192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png" "type": "image/png"
}, },
{ {
"src": "./img/icons/android-chrome-512x512.png", "src": "./images/icons/android-chrome-512x512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png" "type": "image/png"
} }

View file

@ -1,6 +1,5 @@
<template> <template>
<main v-if="loaded"> <main v-if="loaded">
<TopBar v-if="!isFullscreen" />
<router-view /> <router-view />
</main> </main>
<main class="loading-box" v-else> <main class="loading-box" v-else>
@ -9,6 +8,10 @@
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
main {
user-select: none;
}
main.loading-box { main.loading-box {
height: 100vh; height: 100vh;
display: flex; display: flex;
@ -25,15 +28,11 @@ h1.loading-message {
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-property-decorator"; import { Component, Vue } from "vue-property-decorator";
import { Action, Getter } from "vuex-class"; import { Action, Getter } from "vuex-class";
import TopBar from "@/components/Navigation/TopBar.vue"; import { AppState } from "@/store/types";
import { loadSets } from "@/mlpccg/set"; import { refreshCardSource, loadSets, getCards } from "@/mlpccg";
import { AppState } from "./store/types";
import { getCards } from "./mlpccg/database";
@Component({ @Component({
components: { components: {}
TopBar
}
}) })
export default class App extends Vue { export default class App extends Vue {
@Action showLoading!: (msg: string) => void; @Action showLoading!: (msg: string) => void;
@ -54,6 +53,7 @@ export default class App extends Vue {
private async loadCards() { private async loadCards() {
this.showLoading("Downloading data for all sets"); this.showLoading("Downloading data for all sets");
await loadSets(); await loadSets();
await refreshCardSource();
this.hideLoading(); this.hideLoading();
this.setLoaded(true); this.setLoaded(true);
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 50 82" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g id="Artboard1" transform="matrix(1,0,0,1,0,15.7771)">
<rect x="0" y="-15.777" width="50" height="81.873" style="fill:none;"/>
<g transform="matrix(0.457009,0,0,1.2614,2.95268,-32.3672)">
<path d="M48.243,45.605L90.773,73.875L48.243,73.875L5.712,45.605L48.243,17.336L90.773,17.336L48.243,45.605L48.243,45.605Z" style="fill:url(#_Linear1);"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(137.101,65.9713,-182.089,49.6719,-34.1545,12.0871)"><stop offset="0" style="stop-color:rgb(235,235,235);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(163,163,163);stop-opacity:1"/></linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

View file

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View file

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 4 KiB

View file

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

View file

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

View file

Before

Width:  |  Height:  |  Size: 799 B

After

Width:  |  Height:  |  Size: 799 B

View file

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View file

@ -28,7 +28,19 @@ $red: hsl(348, 100%, 61%) !default;
// Typography // Typography
$family-sans-serif: BlinkMacSystemFont, -apple-system, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", "Helvetica", "Arial", sans-serif !default; $family-sans-serif: BlinkMacSystemFont,
-apple-system,
"Segoe UI",
"Roboto",
"Oxygen",
"Ubuntu",
"Cantarell",
"Fira Sans",
"Droid Sans",
"Helvetica Neue",
"Helvetica",
"Arial",
sans-serif !default;
$family-monospace: monospace !default; $family-monospace: monospace !default;
$render-mode: optimizeLegibility !default; $render-mode: optimizeLegibility !default;
@ -72,7 +84,6 @@ $speed: 86ms !default;
$variable-columns: true !default; $variable-columns: true !default;
// The default Bulma derived variables are declared below // The default Bulma derived variables are declared below
$primary: $turquoise !default; $primary: $turquoise !default;
@ -150,3 +161,10 @@ $size-small: $size-7 !default;
$size-normal: $size-6 !default; $size-normal: $size-6 !default;
$size-medium: $size-5 !default; $size-medium: $size-5 !default;
$size-large: $size-4 !default; $size-large: $size-4 !default;
// Input box styling
$input-focus-border-color: $turquoise;
$input-hover-border-color: scale-color($turquoise, $lightness: -30%);
$fantasy: 'Merriweather';
$primary-text: scale-color($primary, $saturation: -50%, $lightness: 20%);

View file

@ -4,6 +4,8 @@
@import "~bulma/sass/utilities/derived-variables"; @import "~bulma/sass/utilities/derived-variables";
@import "~bulma"; @import "~bulma";
@import "~buefy/src/scss/buefy"; @import "~buefy/src/scss/buefy";
@import "dark";
@import url('https://fonts.googleapis.com/css?family=Merriweather:300,400,400i,700&display=swap');
html { html {
scrollbar-color: #404245 #2f3132; scrollbar-color: #404245 #2f3132;
@ -12,5 +14,28 @@ html {
} }
body { body {
color: white; color: $white;
}
.modal-background {
background: rgba($black, 0.6);
}
.modal-content {
border: 1px solid rgba($primary, 0.4);
box-shadow: 0 0 30px rgba($black, 0.3);
}
.modal-card-body {
background: rgba($black, 0.7);
}
.modal-card-head,
.modal-card-foot {
background: rgba($black, 0.9);
}
progress.progress {
filter: invert(90%);
} }

View file

@ -0,0 +1,6 @@
@charset "utf-8"
@import "~bulma-prefers-dark/sass/utilities/_all"
@import "~bulma-prefers-dark/sass/base/_all"
@import "~bulma-prefers-dark/sass/elements/_all"
@import "~bulma-prefers-dark/sass/components/_all"
@import "~bulma-prefers-dark/sass/layout/_all"

View file

@ -0,0 +1,59 @@
<template>
<img :src="imageURL" />
</template>
<style lang="scss" scoped></style>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
import { cardImageURL } from "../../mlpccg";
@Component({
components: {}
})
export default class CardImage extends Vue {
@Prop()
private id!: string;
private loaded!: boolean;
private loadedURL!: string;
private loadedTimeout!: boolean;
private data() {
return {
loaded: false,
loadedURL: "",
loadedTimeout: false
};
}
private mounted() {
this.fetchImage();
setTimeout(() => {
if (!this.loaded) {
this.loadedTimeout = true;
}
}, 100);
this.$watch("id", () => {
this.loaded = false;
this.fetchImage();
});
}
private async fetchImage() {
const url = await cardImageURL(this.id);
this.loaded = true;
this.loadedURL = url;
}
private get imageURL(): string {
if (this.loaded) {
return this.loadedURL;
}
if (this.loadedTimeout) {
return require("@/assets/images/cardback.webp");
}
return "";
}
}
</script>

View file

@ -0,0 +1,98 @@
<template>
<section class="cardpicker" :style="grid">
<article
@click="() => _picked(card)"
:class="cardClass(card)"
v-for="(card, i) in cards"
:key="i + card.data.ID"
>
<CardImage :id="card.data.ID" />
</article>
</section>
</template>
<style lang="scss" scoped>
$padding: 10px;
.cardpicker {
max-height: 100%;
display: grid;
gap: $padding;
padding: $padding;
place-items: stretch;
}
.ccgcard {
display: flex;
align-items: center;
transition: 100ms all;
cursor: pointer;
img {
max-width: 100%;
max-height: 100%;
transition: box-shadow 60ms;
}
&.available:hover img {
box-shadow: 0 0 15px 5px rgba(200, 210, 255, 0.5);
}
&.disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
</style>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
import { Card, CardSlot, cardImageURL } from "@/mlpccg";
import CardImage from "@/components/Cards/CardImage.vue";
@Component({
components: {
CardImage
}
})
export default class CardPicker extends Vue {
@Prop()
public cards!: CardSlot[];
@Prop({ default: 2 })
public rows!: number;
@Prop({ default: 5 })
public columns!: number;
@Prop({ default: false })
public ignoreLimit!: boolean;
private get grid() {
return {
gridTemplateRows: "1fr ".repeat(this.rows).trim(),
gridTemplateColumns: "1fr ".repeat(this.columns).trim()
};
}
private imageURL(id: string) {
return cardImageURL(id);
}
private _picked(card: CardSlot) {
if (this.isAvailable(card)) {
this.$emit("picked", card.data);
}
}
private cardClass(card: CardSlot) {
const available = this.isAvailable(card);
return {
ccgcard: true,
available,
disabled: !available
};
}
private isAvailable(card: CardSlot) {
return card.limit == 0 || card.howmany < card.limit || this.ignoreLimit;
}
}
</script>

View file

@ -0,0 +1,229 @@
<template>
<section class="decklist">
<section class="card-section" v-for="section in sections" :key="section">
<header>
<h1>{{ section }}</h1>
</header>
<article
class="ccgcard"
@click="() => _drop(card)"
v-for="(card, i) in getCards(section, true)"
:key="i"
>
<img :src="imageURL(card.data.ID)" class="cardbg" />
<div class="amt">{{ card.howmany }}</div>
<div class="fullname">
<div class="name">{{ card.data.Name }}</div>
<div class="subname">
{{ card.data.Subname ? card.data.Subname : "" }}
</div>
</div>
</article>
</section>
</section>
</template>
<style lang="scss" scoped>
@import "@/assets/scss/_variables.scss";
.decklist {
display: flex;
flex-direction: column;
}
.card-section {
header {
h1 {
padding: 5px 10px;
font-family: $fantasy;
font-size: 10pt;
}
}
}
.ccgcard {
display: flex;
align-content: space-between;
align-items: center;
border: 1px solid $grey;
border-radius: 10px;
padding: 5px 3px;
margin: 2px 0;
cursor: pointer;
position: relative;
overflow: hidden;
div {
z-index: 2;
}
}
.cardbg {
position: absolute;
margin-top: 30%;
right: -20px;
left: -20px;
max-width: none;
width: 120%;
filter: brightness(20%);
z-index: 1;
}
.fullname {
display: flex;
flex-direction: column;
}
.amt {
margin: 0 6pt 0 10pt;
font-weight: bold;
font-size: 13pt;
&:after {
content: "×";
font-weight: 300;
}
}
.name {
font-family: $fantasy;
font-size: 10.5pt;
line-height: 1rem;
}
.subname {
color: $grey-light;
font-size: 10pt;
line-height: 1rem;
}
</style>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
import {
cardFullName,
CardSlot,
Card,
cardImageURL,
multiElemStr,
typeNames
} from "@/mlpccg";
function sortCards(a: CardSlot, b: CardSlot): number {
// Sort by element
// (Cards are guaranteed to be the same type)
switch (a.data.Type) {
case "Friend":
{
// Sort by requirement
if (a.data.Requirement && b.data.Requirement) {
const reqA = multiElemStr(Object.keys(a.data.Requirement));
const reqB = multiElemStr(Object.keys(b.data.Requirement));
if (reqA > reqB) {
return 1;
}
if (reqB > reqA) {
return -1;
}
}
// Sort by cost
if (a.data.Cost && b.data.Cost) {
if (a.data.Cost > b.data.Cost) {
return 1;
}
if (a.data.Cost < b.data.Cost) {
return -1;
}
}
// Sort by element
const elemA = multiElemStr(a.data.Element);
const elemB = multiElemStr(b.data.Element);
if (elemA > elemB) {
return 1;
}
if (elemB > elemA) {
return -1;
}
}
break;
case "Problem":
if (a.data.ProblemRequirement && b.data.ProblemRequirement) {
const preqA = multiElemStr(Object.keys(a.data.ProblemRequirement));
const preqB = multiElemStr(Object.keys(b.data.ProblemRequirement));
if (preqA > preqB) {
return 1;
}
if (preqB > preqA) {
return -1;
}
}
break;
case "Event":
case "Resource":
if (a.data.Requirement && b.data.Requirement) {
const reqA = multiElemStr(Object.keys(a.data.Requirement));
const reqB = multiElemStr(Object.keys(b.data.Requirement));
if (reqA > reqB) {
return 1;
}
if (reqB > reqA) {
return -1;
}
}
break;
}
// Sort by power
if (a.data.Power && b.data.Power) {
if (a.data.Power > b.data.Power) {
return 1;
}
if (a.data.Power < b.data.Power) {
return -1;
}
}
// If all else fail, sort by name
const nameA = cardFullName(a.data);
const nameB = cardFullName(b.data);
if (nameA > nameB) {
return 1;
}
if (nameA < nameB) {
return -1;
}
return 0;
}
@Component({
components: {}
})
export default class DeckList extends Vue {
@Prop()
public cards!: CardSlot[];
private fullName(card: Card): string {
return cardFullName(card);
}
private _drop(slot: CardSlot) {
this.$emit("removed", slot);
}
private imageURL(id: string) {
return cardImageURL(id);
}
private getCards(section: string, sort: boolean): CardSlot[] {
let cards = this.cards.filter(c => c.data.Type == section);
if (!sort) {
return cards;
}
return cards.sort(sortCards);
}
private get sections(): string[] {
return typeNames.filter(s => this.getCards(s, false).length > 0);
}
}
</script>

View file

@ -1,14 +0,0 @@
<template>
<nav></nav>
</template>
<style lang="scss" scoped></style>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
@Component({
components: {}
})
export default class TopBar extends Vue {}
</script>

View file

@ -0,0 +1,124 @@
<template>
<nav>
<section class="pages">
<router-link
:class="routeClass(route)"
v-for="route in mainRoutes"
:key="route"
:to="{ name: route }"
>{{ prettyTitle(route) }}</router-link
>
</section>
<section class="icons">
<router-link
:class="routeClass(route)"
v-for="route in iconRoutes"
:key="route"
:to="{ name: route }"
><b-icon
:icon="prettyTitle(route)"
class="route-icon"
custom-size="mdi-36px"
/></router-link>
</section>
</nav>
</template>
<style lang="scss" scoped>
@import "@/assets/scss/_variables.scss";
.route-icon {
width: 50px;
}
nav {
margin-bottom: 1rem;
display: flex;
background: linear-gradient(
to bottom,
rgba(200, 230, 250, 0.3),
rgba(100, 180, 255, 0.1)
);
.pages {
flex: 1;
display: flex;
}
.icons {
flex-grow: 0;
display: flex;
}
.entry {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
height: 50px;
max-width: 250px;
text-align: center;
color: $grey-lighter;
border-right: 1px solid rgba(0, 50, 100, 0.5);
&:hover {
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.5),
rgba(100, 100, 100, 0.1)
);
cursor: pointer;
color: $white;
}
&.current,
&.current:hover {
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.7),
rgba(100, 100, 100, 0.1)
);
color: $grey-lighter;
cursor: normal;
}
}
}
</style>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
const mainRoutes = ["lobby", "deck-editor"];
const iconRoutes = ["settings"];
@Component({
components: {}
})
export default class TopNav extends Vue {
private mainRoutes!: string[];
private iconRoutes!: string[];
private data() {
return {
mainRoutes,
iconRoutes
};
}
private prettyTitle(name: string): string {
const route = this.$router.resolve({ name });
if (!route || !route.resolved.meta || !route.resolved.meta.topnav) {
return name;
}
return route.resolved.meta.topnav;
}
private routeClass(name: string) {
return {
entry: true,
current: this.$route.name == name
};
}
}
</script>

View file

@ -1,15 +1,19 @@
import Vue from "vue"; import Vue from "vue";
import "./plugins/axios";
import App from "./App.vue"; import App from "./App.vue";
import router from "./router"; import router from "./router";
import store from "./store"; import store from "./store";
import "./registerServiceWorker"; import "./registerServiceWorker";
import Buefy from "buefy"; import Buefy from "buefy";
import "./assets/scss/app.scss"; import "./assets/scss/app.scss";
import { initDB } from "./mlpccg";
Vue.use(Buefy); Vue.use(Buefy);
Vue.config.productionTip = false; Vue.config.productionTip = false;
initDB();
new Vue({ new Vue({
router, router,
store, store,

View file

@ -1,5 +1,71 @@
const imgBaseURL = "https://mcg.zyg.ovh/images/cards/"; import { Card } from "./types";
export function cardImageURL(cardid: string): string { export function cardFullName(card: Card): string {
return `${imgBaseURL}${cardid}.webp`; if (card.Subname != "") {
return `${card.Name}, ${card.Subname}`;
}
return card.Name;
}
export function createPonyheadURL(cards: Card[]): string {
const cardlist = cards.map(c => `${c.ID}x1`);
return "https://ponyhead.com/deckbuilder?v1code=" + cardlist.join("-");
}
export const colorNames = [
"Loyalty",
"Honesty",
"Laughter",
"Magic",
"Generosity",
"Kindness",
"None"
];
export const typeNames = [
"Mane Character",
"Friend",
"Event",
"Resource",
"Troublemaker",
"Problem"
];
export const rarityNames = ["C", "U", "R", "SR", "UR", "RR", "F"];
// Trasform string from list to a number that can be used for comparison/sorting
function arrIndex(arr: string[]) {
return function(comp: string) {
const idx = arr.indexOf(comp);
if (idx < 0) {
return arr.length;
}
return idx;
};
}
export const elemIndex = arrIndex(colorNames);
export const typeIndex = arrIndex(typeNames);
export const rarityIndex = arrIndex(rarityNames);
// Convert Element[] to number by scaling elements for fair comparisons
// Example: ["Loyalty", "Kindness"] -> [0, 5] -> [1, 6] -> 16
export function multiElemStr(elems: string[]): number {
return elems
.map(elemIndex)
.reduce(
(acc, elem, idx, arr) => acc + (elem + 1) * 10 ** (arr.length - idx - 1),
0
);
}
export function cardLimit(type: string) {
switch (type) {
case "Mane Character":
return 1;
case "Problem":
return 2;
default:
return 3;
}
} }

View file

@ -1,21 +1,42 @@
import Dexie from "dexie"; import Dexie from "dexie";
import { Card, CardFilter } from "./types"; import { Card, CardFilter, StoredImage } from "./types";
import { cardFullName } from "./card";
class CardDatabase extends Dexie { class CardDatabase extends Dexie {
public cards: Dexie.Table<Card, string>; public cards: Dexie.Table<Card, string>;
public images: Dexie.Table<StoredImage, string>;
public constructor() { public constructor() {
super("CardDatabase"); super("CardDatabase");
this.version(1).stores({ this.version(1).stores({
cards: "ID,Set,Type,Cost,Power" cards: "ID,Set,Type,Cost,Power",
images: "id"
}); });
this.cards = this.table("cards"); this.cards = this.table("cards");
this.images = this.table("images");
} }
} }
export let Database = new CardDatabase(); export let Database: CardDatabase | null = null;
export async function initDB(): Promise<void> {
return new Promise(resolve => {
if (Database == null) {
Database = new CardDatabase();
Database.on("ready", async () => {
resolve();
});
Database.open();
} else {
resolve();
}
});
}
export async function getCards(filter: CardFilter) { export async function getCards(filter: CardFilter) {
if (Database == null) {
throw new Error("Database was not initialized, init with 'initDB()'");
}
let table = Database.cards; let table = Database.cards;
// Get best IDB index // Get best IDB index
let query: Dexie.Collection<Card, string>; let query: Dexie.Collection<Card, string>;
@ -43,11 +64,10 @@ export async function getCards(filter: CardFilter) {
} }
} }
return await query const results = query.filter(x => {
.filter(x => {
if (filter.Name) { if (filter.Name) {
if ( if (
!`${x.Name}, ${x.Subname}` !cardFullName(x)
.toLowerCase() .toLowerCase()
.includes(filter.Name.toLowerCase()) .includes(filter.Name.toLowerCase())
) { ) {
@ -109,15 +129,21 @@ export async function getCards(filter: CardFilter) {
} }
} }
} }
// For "None" element searches, "nothing" is actually ok
if (
filter.Elements.includes("None") &&
x.Element.length == 0 &&
(!x.Requirement || x.Requirement.length == 0) &&
(!x.ProblemRequirement || x.ProblemRequirement.length == 0)
) {
found = true;
}
if (!found) { if (!found) {
return false; return false;
} }
} }
if (filter.Powers && filter.Powers.length > 0) { if (filter.Powers && filter.Powers.length > 0) {
if ( if (typeof x.Power === "undefined" || !filter.Powers.includes(x.Power)) {
typeof x.Power === "undefined" ||
!filter.Powers.includes(x.Power)
) {
return false; return false;
} }
} }
@ -132,6 +158,18 @@ export async function getCards(filter: CardFilter) {
} }
} }
return true; return true;
}) });
return await results.toArray();
}
export async function cardFromIDs(cardIDs: string[]): Promise<Card[]> {
if (Database == null) {
throw new Error("Database was not initialized, init with 'initDB()'");
}
let table = Database.cards;
//TODO Replace with .bulkGet when upgrading to Dexie 3.x
return await table
.where("ID")
.anyOf(cardIDs)
.toArray(); .toArray();
} }

211
src/mlpccg/draft/booster.ts Normal file
View file

@ -0,0 +1,211 @@
import { Card, getCards } from "@/mlpccg";
import { Pack, PackSchema, AlternateProvider } from "./types";
/*
(This data was taken from the MLP:CCG wikia at mlpccg.fandom.com and confirmed
by people at the MLP:CCG Discord)
Distribution rates for packs is usually 8 commons, 3 uncommons and 1 rare.
No Fixed or Promo cards can be found in packs.
UR distribution depends on set:
- PR has 1/13 chance of UR replacing a common
- CN->AD has 1/11 chance of UR replacing a common
- EO->FF has 1/3 chance of SR/UR replacing a common
SR are twice as common as UR, so that's one more thing to keep in mind.
Lastly, RR can replace another common in the ratio of ~1/2 every 6 boxes, depending
on set. Specifically, this is the RR ratio for each set:
- EO->HM: 1/108
- MT->FF: 1/216
*/
// Returns the pack schema for a specific set
async function setSchema(set: string): Promise<PackSchema> {
// Force set name to uppercase
set = set.toUpperCase();
// Return blank schemas for invalid sets
if (set == "RR" || set == "CS") {
return { slots: [] };
}
// Get cards for set
let cards = await getCards({ Sets: [set] });
let cardMap = spanByRarity(cards);
let rr: AlternateProvider[] = [];
let srur: AlternateProvider[] = [];
// Check for RR chances
/*
switch (set) {
case "EO":
case "HM":
rr = [
{
probability: 1.0 / 108.0,
provider: randomProvider([
//TODO
])
}
];
break;
case "MT":
case "HM":
case "SB":
case "FF":
rr = [
{
probability: 1.0 / 216.0,
provider: randomProvider([
//TODO
])
}
];
break;
}
*/
// Check for SR/UR chances
switch (set) {
case "PR":
srur = [
{
probability: 1.0 / 13.0,
provider: randomProvider(cardMap["UR"])
}
];
break;
case "CN":
case "CG":
case "AD":
srur = [
{
probability: 1.0 / 11.0,
provider: randomProvider(cardMap["UR"])
}
];
break;
default:
srur = [
{
probability: (1.0 / 9.0) * 2.0,
provider: randomProvider(cardMap["SR"])
},
{
probability: 1.0 / 9.0,
provider: randomProvider(cardMap["UR"])
}
];
break;
}
return {
slots: [
{
amount: 6,
provider: randomProvider(cardMap["C"]),
alternate: []
},
{
amount: 1,
provider: randomProvider(cardMap["C"]),
alternate: rr
},
{
amount: 1,
provider: randomProvider(cardMap["C"]),
alternate: srur
},
{
amount: 1,
provider: randomProvider(cardMap["R"]),
alternate: []
},
{
amount: 3,
provider: randomProvider(cardMap["U"]),
alternate: []
}
]
};
}
export class PackBuilder {
schema: PackSchema;
constructor(schema: PackSchema) {
this.schema = schema;
}
buildPack(): Pack {
let pack = [];
for (const slot of this.schema.slots) {
let provider = slot.provider;
// Check for alternates by generating a random and checking cumulated
// probability. Ie. if one card would show 5% of the time, another would
// show up 10% of the time, the algorithm would do something like this:
//
// With Math.random() == 0.85:
// ALTERNATE NO ALTERNATE
// [0.00-0.05][0.06----0.15][0.16------------1.00]
// ^ 0.85
//
// With Math.random() == 0.03:
// ALTERNATE NO ALTERNATE
// [0.00-0.05][0.06----0.15][0.16------------1.00]
// ^ 0.03
const rnd = Math.random();
let currentProb = 0;
for (const alternate of slot.alternate) {
currentProb += alternate.probability;
// Alternate matched
if (currentProb > rnd) {
provider = alternate.provider;
break;
}
}
for (let i = 0; i < slot.amount; i++) {
const res = provider.next();
if (res.done) {
// No more cards to get from this, exit early
break;
}
pack.push(res.value);
}
}
return pack;
}
static async fromSet(set: string): Promise<PackBuilder> {
let schema = await setSchema(set);
let builder = new PackBuilder(schema);
return builder;
}
}
// Yields random cards from a chosen pool
export function* randomProvider(pool: Card[]) {
while (true) {
const idx = Math.floor(Math.random() * pool.length);
yield pool[idx];
}
}
// Divides a list of card to a map of rarities
// ie. [ff14, ff16, ff17] => { "C" : ["ff14"], "U": ["ff17"], "R": ["ff16"] }
export function spanByRarity(pool: Card[]): Record<string, Card[]> {
return pool.reduce((map, current) => {
if (!(current.Rarity in map)) {
map[current.Rarity] = [];
}
map[current.Rarity].push(current);
return map;
}, Object.create(null));
}

18
src/mlpccg/draft/bot.ts Normal file
View file

@ -0,0 +1,18 @@
import { Card } from "@/mlpccg";
import { SessionPlayer } from "./session";
export class DraftBot {
assign(player: SessionPlayer) {
player.on("available-picks", cards => {
const pick = this.pick(cards);
// setTimeout hack to avoid handlers being called before the rest of the code
setTimeout(() => player.pick(pick.ID), 0);
});
}
pick(picks: Card[]): Card {
// For now, pick a random card
const idx = Math.floor(Math.random() * picks.length);
return picks[idx];
}
}

48
src/mlpccg/draft/cube.ts Normal file
View file

@ -0,0 +1,48 @@
import { Card, cardFromIDs } from "@/mlpccg";
import { PackSchema } from "./types";
import axios from "axios";
export class Cube {
private pool: Card[];
constructor(pool: Card[]) {
this.pool = pool;
}
schema(): PackSchema {
return {
slots: [
{
amount: 15,
provider: this.provider(),
alternate: []
}
]
};
}
*provider() {
while (this.pool.length > 0) {
const idx = Math.floor(Math.random() * this.pool.length);
const card = this.pool.splice(idx, 1);
yield card[0];
}
}
static async fromCardIDs(cardIDs: string[]): Promise<Cube> {
const cards = await cardFromIDs(cardIDs);
return new this(cards);
}
static async fromList(list: string): Promise<Cube> {
const ids = list.split("\n").map(x => x.trim());
return await this.fromCardIDs(ids);
}
static async fromURL(url: string) {
const res = await axios(url, {
responseType: "text"
});
return await this.fromList(res.data);
}
}

View file

@ -0,0 +1,81 @@
import { Card, cardFromIDs } from "@/mlpccg";
import {
PackSchema,
I8PCubeSchema,
I8PPackSchema,
I8PFileSchema,
DraftSchema
} from "./types";
import axios from "axios";
import { PackBuilder } from "./booster";
export class I8PCube {
private pools: Record<string, Card[]>;
private packschema: I8PPackSchema[];
private problemCount: number;
constructor(cubefile: I8PCubeSchema) {
this.pools = cubefile.Cards;
this.packschema = cubefile.Schema;
this.problemCount = cubefile.ProblemPackSize;
}
schema(): DraftSchema {
return {
boosters: {
main: 4,
problem: 1
},
factories: {
main: new PackBuilder({
slots: this.packschema.map(s => ({
amount: s.Amount,
provider: this.provider(s.Type),
alternate: []
}))
}),
problem: new PackBuilder({
slots: [
{
amount: this.problemCount,
provider: this.provider("problem"),
alternate: []
}
]
})
}
};
}
*provider(name: string | "all") {
let poolname = name;
while (true) {
if (name == "all") {
const pools = Object.keys(this.pools);
const idx = Math.floor(Math.random() * pools.length);
poolname = pools[idx];
}
const pool = this.pools[poolname];
if (pool.length <= 0) {
return;
}
const idx = Math.floor(Math.random() * pool.length);
const card = pool.splice(idx, 1);
yield card[0];
}
}
static async fromURL(url: string) {
const res = await axios(url);
const cubefile = res.data as I8PFileSchema;
let cards: Record<string, Card[]> = {};
for (const pool in cubefile.Cards) {
cards[pool] = await cardFromIDs(cubefile.Cards[pool]);
}
return new this({
Cards: cards,
ProblemPackSize: cubefile.ProblemPackSize,
Schema: cubefile.Schema
});
}
}

View file

@ -0,0 +1,5 @@
export * from "./cube";
export * from "./booster";
export * from "./types";
export * from "./session";
export * from "./bot";

View file

@ -0,0 +1,34 @@
import { DraftSchema, Pack } from "./types";
import { PackBuilder } from "./booster";
export class DraftProvider {
private schema: DraftSchema;
constructor(schema: DraftSchema) {
this.schema = schema;
}
getPacks(): Pack[] {
let out = [];
for (const boosterSlot in this.schema.boosters) {
const amount = this.schema.boosters[boosterSlot];
const factory = this.schema.factories[boosterSlot];
if (!factory) {
throw new Error(
`booster type ${boosterSlot} was referenced in schema but was not provided a builder`
);
}
for (let i = 0; i < amount; i++) {
out.push(factory.buildPack());
}
}
return out;
}
static set(factory: PackBuilder, amount: number): DraftProvider {
return new DraftProvider({
boosters: { normal: amount },
factories: { normal: factory }
});
}
}

262
src/mlpccg/draft/session.ts Normal file
View file

@ -0,0 +1,262 @@
import { PackBuilder, Cube, DraftOptions } from ".";
import EventEmitter from "eventemitter3";
import { Card } from "@/mlpccg";
import { Pack, Direction } from "./types";
import { DraftProvider } from "./provider";
import { DraftBot } from "./bot";
import { I8PCube } from "./i8pcube";
export class Session extends EventEmitter {
private options: DraftOptions;
private provider: DraftProvider;
private pod: SessionPlayer[] = [];
private players: string[] = [];
private pending: number[] = [];
private assigned: boolean = false;
private direction: Direction = "cw";
constructor(options: DraftOptions, provider: DraftProvider) {
super();
this.options = options;
this.provider = provider;
this.pod = new Array(options.players).fill(0).map((x, i) => {
const player = new SessionPlayer(provider.getPacks());
player.on("pick", this.picked.bind(this, i));
return player;
});
// Populate prev/next references
this.pod.forEach((val, i) => {
if (i > 0) {
val.prev = this.pod[i - 1];
} else {
val.prev = this.pod[this.pod.length - 1];
}
if (i < this.pod.length - 1) {
val.next = this.pod[i + 1];
} else {
val.next = this.pod[0];
}
});
}
public assign(
players: string[],
assignFn: (name: string, instance: SessionPlayer) => void
) {
// Figure out how many players there are vs spots to be filled
this.players = players;
const spots = this.options.players;
const playerNum = players.length;
if (playerNum > spots) {
throw new Error("too many players in the pod");
}
if (playerNum < 1) {
throw new Error("not enough players");
}
// Place players in the pod
switch (this.options.spacing) {
case "evenly": {
const playerRatio = spots / playerNum;
let i = 0;
for (const player of players) {
const pos = Math.floor(playerRatio * i);
this.pod[pos].name = player;
assignFn(player, this.pod[pos]);
i += 1;
}
break;
}
case "randomly":
for (const player of players) {
const free = [...Array(spots).keys()].filter(
i => this.pod[i].name == ""
);
const idx = Math.floor(Math.random() * free.length);
const chosen = free[idx];
assignFn(player, this.pod[chosen]);
}
break;
}
// All the non-assigned places go to bots!
this.pod.forEach(p => {
if (p.name == "") {
p.name = "bot";
const bot = new DraftBot();
bot.assign(p);
}
});
this.assigned = true;
}
public start() {
if (!this.assigned) {
throw new Error("Must assign players first (see assign())");
}
this.emit("start", this.order);
this.nextPack();
}
public get order(): string[] {
return this.pod.map(p => p.name);
}
private picked(
playerIndex: number,
_card: string,
lastPick: boolean,
lastPack: boolean
) {
if (!this.pending.includes(playerIndex)) {
// Uh oh.
throw new Error(
`unexpected pick: player "${this.pod[playerIndex].name}" already picked their card`
);
}
const idx = this.pending.indexOf(playerIndex);
this.pending.splice(idx, 1);
this.emit("player-pick", this.pod[playerIndex].name);
// Don't continue unless everyone picked their card
if (this.pending.length > 0) {
return;
}
// Was this the last pick for this round of packs?
if (lastPick) {
// Was the it the last pack?
if (lastPack) {
this.emit("draft-over");
this.pod.forEach(p => p.emit("your-picks", p.picks));
return;
}
this.nextPack();
return;
}
// Pass packs between players for next pick
this.resetPending();
this.pod.forEach(p => p.sendPack(this.direction));
this.emit("next-pick");
}
private nextPack() {
this.resetPending();
this.flipOrder();
this.pod.forEach(p => p.nextPack());
this.emit("next-pack");
}
private resetPending() {
this.pending = this.pod.map((_, i) => i);
}
private flipOrder() {
if (this.direction == "cw") {
this.direction = "ccw";
} else {
this.direction = "cw";
}
}
static async create(options: DraftOptions): Promise<Session> {
switch (options.source) {
case "set": {
const factory = await PackBuilder.fromSet(options.set);
const provider = DraftProvider.set(factory, options.packs);
return new Session(options, provider);
}
case "block":
throw new Error("not implemented");
case "cube": {
const cube = await Cube.fromURL(options.url);
const factory = new PackBuilder(cube.schema());
const provider = DraftProvider.set(factory, options.packs);
return new Session(options, provider);
}
case "i8pcube": {
const cube = await I8PCube.fromURL(options.url);
const provider = new DraftProvider(cube.schema());
return new Session(options, provider);
}
default:
throw new Error("Unknown draft source");
}
}
}
export class SessionPlayer extends EventEmitter {
public name: string = "";
public currentPack?: Pack;
public picks: Pack;
public packs: Pack[];
public toSend: Pack | null = null;
public next?: SessionPlayer;
public prev?: SessionPlayer;
public ready: boolean = false;
constructor(packs: Pack[]) {
super();
this.packs = packs;
this.picks = [];
}
public pick(card: string) {
if (!this.ready) {
throw new Error("not ready to pick");
}
if (!this.currentPack) {
throw new Error("no pack to pick from");
}
const idx = this.currentPack.findIndex(c => c.ID == card);
if (idx < 0) {
throw new Error("card not in available picks");
}
const pick = this.currentPack.splice(idx, 1);
this.picks.push(pick[0]);
this.toSend = this.currentPack;
this.ready = false;
this.emit("pick", card, this.currentPack.length < 1, this.packs.length < 1);
}
public sendPack(direction: Direction) {
if (!this.toSend) {
throw new Error("no pack to pass");
}
if (this.toSend.length < 1) {
throw new Error("empty pack");
}
if (direction == "cw") {
if (!this.next) {
throw new Error("no player to pass cards to");
}
this.next.receivePack(this.toSend);
} else {
if (!this.prev) {
throw new Error("no player to pass cards to");
}
this.prev.receivePack(this.toSend);
}
this.toSend = null;
}
public receivePack(cards: Pack) {
this.currentPack = cards;
this.ready = true;
this.emit("available-picks", cards);
}
public nextPack() {
// Open new pack
const newPack = this.packs.shift();
if (!newPack) {
throw new Error("no packs left");
}
this.receivePack(newPack);
}
}

90
src/mlpccg/draft/types.ts Normal file
View file

@ -0,0 +1,90 @@
import { Card } from "@/mlpccg";
import { PackBuilder } from "./booster";
export type Provider = Iterator<Card>;
export type Pack = Card[];
export interface PackSchema {
slots: PackSlot[];
}
export interface PackSlot {
amount: number;
provider: Provider;
alternate: AlternateProvider[];
}
export interface AlternateProvider {
probability: number;
provider: Provider;
}
export interface SetDraftOptions {
source: "set";
set: string;
}
export interface BlockDraftOptions {
source: "block";
block: string;
}
export interface CubeDraftOptions {
source: "cube";
url: string;
}
export interface I8PCubeDraftOptions {
source: "i8pcube";
url: string;
}
export interface LimitedBoosterDraft {
type: "booster-draft";
packs: number;
}
export interface LimitedSealedDraft {
type: "sealed";
packs: number;
}
export type LimitedGameType = LimitedBoosterDraft | LimitedSealedDraft;
export type DraftType =
| SetDraftOptions
| BlockDraftOptions
| CubeDraftOptions
| I8PCubeDraftOptions;
export interface SessionOptions {
players: number;
spacing: "evenly" | "randomly";
}
export type DraftOptions = SessionOptions & LimitedGameType & DraftType;
export interface DraftSchema {
boosters: Record<string, number>;
factories: Record<string, PackBuilder>;
}
export type Direction = "cw" | "ccw";
export interface I8PCubeSchema {
Schema: I8PPackSchema[];
ProblemPackSize: number;
Cards: Record<string, Card[]>;
}
export interface I8PFileSchema {
Schema: I8PPackSchema[];
ProblemPackSize: number;
Cards: Record<string, string[]>;
}
export interface I8PPackSchema {
Amount: number;
Type: string;
}

38
src/mlpccg/images.ts Normal file
View file

@ -0,0 +1,38 @@
import { Database } from "./database";
const imgBaseURL = "https://mcg.zyg.ovh/images/cards/";
let imageSource: "local" | "remote" = "remote";
export function remoteImageURL(cardid: string): string {
return `${imgBaseURL}${cardid}.webp`;
}
export async function cardImageURL(cardid: string): Promise<string> {
if (!Database) {
return remoteImageURL(cardid);
}
switch (cardImageSource()) {
case "local": {
const card = await Database.images.get(`${cardid}.webp`);
if (!card) {
return remoteImageURL(cardid);
}
return URL.createObjectURL(card.image);
}
//TODO
case "remote":
return remoteImageURL(cardid);
}
}
export function cardImageSource() {
return imageSource;
}
export async function refreshCardSource() {
if (!Database) {
return;
}
const count = await Database.images.count();
imageSource = count > 1900 ? "local" : "remote";
}

5
src/mlpccg/index.ts Normal file
View file

@ -0,0 +1,5 @@
export * from "./card";
export * from "./database";
export * from "./set";
export * from "./types";
export * from "./images";

View file

@ -1,8 +1,9 @@
import { SetFile } from "./types"; import { SetFile } from "./types";
import { Database } from "./database"; import { Database } from "./database";
import axios from "axios";
const baseURL = "https://mcg.zyg.ovh/setdata/"; const baseURL = "https://mcg.zyg.ovh/setdata/";
const allSets = [ export const allSets = [
"PR", "PR",
"CN", "CN",
"RR", "RR",
@ -18,7 +19,27 @@ const allSets = [
"Promo" "Promo"
]; ];
export const setNames = {
PR: "Premiere",
CN: "Canterlot Nights",
RR: "Rock and Rave",
CS: "Celestial Solstice",
CG: "Crystal Games",
AD: "Absolute Discord",
EO: "Equestrian Odysseys",
HM: "High Magic",
MT: "Marks In Time",
DE: "Defenders of Equestria",
SB: "Seaquestria and Beyond",
FF: "Friends Forever",
GF: "Promotional",
ST: "Sands of Time"
};
export async function loadSets() { export async function loadSets() {
if (Database == null) {
throw new Error("Database was not initialized, init with 'initDB()'");
}
const itemcount = await Database.cards.count(); const itemcount = await Database.cards.count();
if (itemcount > 100) { if (itemcount > 100) {
// DB already filled, exit early // DB already filled, exit early
@ -27,18 +48,19 @@ export async function loadSets() {
const sets = await Promise.all(allSets.map(set => downloadSet(set))); const sets = await Promise.all(allSets.map(set => downloadSet(set)));
await Promise.all( await Promise.all(
sets.map(async set => { sets.map(async set => {
console.log(`Processing cards from ${set.Name}`); if (Database == null) {
throw new Error("Database was not initialized, init with 'initDB()'");
}
return await Database.cards.bulkPut(set.Cards); return await Database.cards.bulkPut(set.Cards);
}) })
); );
} }
async function downloadSet(setid: string): Promise<SetFile> { async function downloadSet(setid: string): Promise<SetFile> {
const setfile = await fetch(`${baseURL}${setid.toLowerCase()}.json`); const setdata = await axios(`${baseURL}${setid.toLowerCase()}.json`);
const setdata: SetFile = await setfile.json(); setdata.data.Cards = (setdata.data as SetFile).Cards.map(c => {
setdata.Cards = setdata.Cards.map(c => {
c.Set = setid; c.Set = setid;
return c; return c;
}); });
return setdata; return setdata.data;
} }

View file

@ -2,6 +2,11 @@ export type Rarity = "C" | "U" | "R" | "SR" | "UR" | "RR";
export type PowerRequirement = { [key: string]: number }; export type PowerRequirement = { [key: string]: number };
export interface StoredImage {
id: string;
image: Blob;
}
export interface SetFile { export interface SetFile {
Name: string; Name: string;
Cards: Card[]; Cards: Card[];
@ -38,3 +43,9 @@ export interface CardFilter {
Powers?: number[]; Powers?: number[];
Rarities?: string[]; Rarities?: string[];
} }
export interface CardSlot {
data: Card;
limit: number;
howmany: number;
}

View file

@ -1,10 +1,7 @@
import {
PeerMetadata,
NetworkMessage,
PasswordResponse,
RoomInfo
} from "./types";
import EventEmitter from "eventemitter3"; import EventEmitter from "eventemitter3";
import Vue from "vue";
import { NetworkMessage, PasswordResponse, PeerMetadata, RoomInfo } from "./types";
export abstract class Client extends EventEmitter { export abstract class Client extends EventEmitter {
public metadata: PeerMetadata; public metadata: PeerMetadata;
@ -23,13 +20,19 @@ export abstract class Client extends EventEmitter {
case "room-info": case "room-info":
this.roomInfo = data.room; this.roomInfo = data.room;
this.players = data.players; this.players = data.players;
this.emit("handshake");
break; break;
// Someone changed name (or was forced to) // Someone changed name (or was forced to)
case "rename": case "rename":
if (data.oldname == this.metadata.name) { if (data.oldname == this.metadata.name) {
// We got a name change! // We got a name change!
this.metadata.name = data.newname; this.metadata.name = data.newname;
} else { }
// Only mutate player list if we have one
// This is because rename messages can be received during the initial
// handshake, to signal a forced name change before joining.
if (this.players) {
let idx = this.players.indexOf(data.oldname); let idx = this.players.indexOf(data.oldname);
if (idx < 0) { if (idx < 0) {
// Weird // Weird
@ -39,7 +42,7 @@ export abstract class Client extends EventEmitter {
this.players.push(data.newname); this.players.push(data.newname);
break; break;
} }
this.players[idx] = data.newname; Vue.set(this.players, idx, data.newname);
} }
this.emit("rename", data.oldname, data.newname); this.emit("rename", data.oldname, data.newname);
break; break;
@ -48,7 +51,7 @@ export abstract class Client extends EventEmitter {
this.players.push(data.name); this.players.push(data.name);
this.emit("player-joined", data.name); this.emit("player-joined", data.name);
break; break;
case "player-left": case "player-left": {
let idx = this.players.indexOf(data.name); let idx = this.players.indexOf(data.name);
if (idx < 0) { if (idx < 0) {
// Weird // Weird
@ -57,9 +60,13 @@ export abstract class Client extends EventEmitter {
); );
break; break;
} }
this.emit("player-left", data.name);
this.players.splice(idx, 1); this.players.splice(idx, 1);
break;
}
case "password-req": case "password-req":
this.emit("password-required"); this.emit("password-required");
break;
default: default:
// For most cases, we can just use the kind as event type // For most cases, we can just use the kind as event type
this.emit(data.kind, data); this.emit(data.kind, data);

View file

@ -25,6 +25,12 @@ export class PeerClient extends Client {
this.connection.on("open", () => { this.connection.on("open", () => {
this.emit("connected"); this.emit("connected");
}); });
this.connection.on("close", () => {
this.emit("disconnected");
});
this.connection.on("error", err => {
this.emit("error", err);
});
this.connection.on("data", data => { this.connection.on("data", data => {
this._received(data); this._received(data);
}); });
@ -34,6 +40,10 @@ export class PeerClient extends Client {
return this.metadata.name; return this.metadata.name;
} }
public get id(): string {
return this.peer.id;
}
public send<T extends NetworkMessage>(data: T) { public send<T extends NetworkMessage>(data: T) {
if (!this.connection) { if (!this.connection) {
throw new Error("Client is not connected to a server"); throw new Error("Client is not connected to a server");

View file

@ -1,23 +1,24 @@
import EventEmitter from "eventemitter3";
import Peer, { DataConnection } from "peerjs"; import Peer, { DataConnection } from "peerjs";
import { LocalClient } from ".";
import { import {
RoomInfo, AckMessage,
PasswordRequest, ChatMessage,
Room,
ErrorMessage, ErrorMessage,
JoinMessage,
LeaveMessage,
NetworkMessage,
NetworkPlayer,
PasswordRequest,
PasswordResponse, PasswordResponse,
PeerMetadata, PeerMetadata,
JoinMessage,
RoomInfoMessage,
Player, Player,
NetworkMessage,
RenameMessage, RenameMessage,
LeaveMessage, Room,
NetworkPlayer, RoomInfo,
AckMessage, RoomInfoMessage,
ChatMessage
} from "./types"; } from "./types";
import { LocalClient } from ".";
import EventEmitter from "eventemitter3";
// Increment name, add number at the end if not present // Increment name, add number at the end if not present
// Examples: // Examples:
@ -34,6 +35,12 @@ function nextName(name: string): string {
return name.substr(0, i) + (Number(name.slice(i)) + 1); return name.substr(0, i) + (Number(name.slice(i)) + 1);
} }
function connectionOpen(conn: DataConnection): Promise<void> {
return new Promise(resolve => {
conn.on("open", () => resolve());
});
}
export class PeerServer extends EventEmitter { export class PeerServer extends EventEmitter {
protected peer: Peer; protected peer: Peer;
private room: Room; private room: Room;
@ -64,17 +71,21 @@ export class PeerServer extends EventEmitter {
// Setup peer // Setup peer
this.peer = customPeer ? customPeer : new Peer(); this.peer = customPeer ? customPeer : new Peer();
this.peer.on("open", function(id) { this.peer.on("open", id => {
console.info("Peer ID assigned: %s", id); console.info("Peer ID assigned: %s", id);
this.emit("open", id);
}); });
this.peer.on("connection", conn => { this.peer.on("connection", conn => {
this._connection(conn); this._connection(conn);
}); });
} }
private _connection(conn: DataConnection) { private async _connection(conn: DataConnection) {
const metadata = conn.metadata as PeerMetadata; const metadata = conn.metadata as PeerMetadata;
// Wait for connection to be open
await connectionOpen(conn);
let player: NetworkPlayer = { let player: NetworkPlayer = {
kind: "remote", kind: "remote",
name: metadata.name, name: metadata.name,
@ -140,6 +151,12 @@ export class PeerServer extends EventEmitter {
private addPlayer(player: NetworkPlayer) { private addPlayer(player: NetworkPlayer) {
const playerName = player.name; const playerName = player.name;
// Hacky: Give player list before this player was added, so join
// message doesn't mess things up later
const players = Object.keys(this.room.players);
// Add player to room
this.room.players[playerName] = player; this.room.players[playerName] = player;
// Start listening for new messages // Start listening for new messages
@ -148,6 +165,10 @@ export class PeerServer extends EventEmitter {
this._received.bind(this, this.room.players[playerName]) this._received.bind(this, this.room.players[playerName])
); );
player.conn.on("error", err => {
throw err;
});
// Send the player info about the room // Send the player info about the room
this.send<RoomInfoMessage>(player, { this.send<RoomInfoMessage>(player, {
kind: "room-info", kind: "room-info",
@ -155,7 +176,7 @@ export class PeerServer extends EventEmitter {
...this.room.info, ...this.room.info,
password: "" password: ""
}, },
players: Object.keys(this.room.players) players
}); });
// Notify other players // Notify other players
@ -172,6 +193,9 @@ export class PeerServer extends EventEmitter {
// Close connection with player // Close connection with player
player.conn.close(); player.conn.close();
// Remove player from player list
delete this.room.players[player.name];
// Notify other players // Notify other players
this.broadcast<LeaveMessage>({ this.broadcast<LeaveMessage>({
kind: "player-left", kind: "player-left",
@ -189,16 +213,30 @@ export class PeerServer extends EventEmitter {
this.broadcast(data); this.broadcast(data);
} else { } else {
// Player is telling someone specifically // Player is telling someone specifically
if (data.to in this.players) { if (!(data.to in this.players)) {
this.send<ChatMessage>(this.players[data.to], data);
} else {
this.send<ErrorMessage>(player, { this.send<ErrorMessage>(player, {
kind: "error", kind: "error",
error: `player not found: ${data.to}` error: `player not found: ${data.to}`
}); });
return;
} }
this.send<ChatMessage>(this.players[data.to], data);
} }
break; break;
// Player wants to change name
case "rename":
// Make sure new name is valid
data.oldname = player.name;
if (data.newname in this.players) {
this.send<ErrorMessage>(player, {
kind: "error",
error: "name not available"
});
return;
}
player.name = data.newname;
this.broadcast<RenameMessage>(data);
break;
// Player is leaving! // Player is leaving!
case "leave-req": case "leave-req":
// If we're leaving, end the server // If we're leaving, end the server
@ -234,6 +272,10 @@ export class PeerServer extends EventEmitter {
this.send<T>(player, message); this.send<T>(player, message);
} }
} }
public get id(): string {
return this.peer.id;
}
} }
export default PeerServer; export default PeerServer;

61
src/plugins/axios.js Normal file
View file

@ -0,0 +1,61 @@
"use strict";
import Vue from "vue";
import axios from "axios";
// Full config: https://github.com/axios/axios#request-config
// axios.defaults.baseURL = process.env.baseURL || process.env.apiUrl || '';
// axios.defaults.headers.common['Authorization'] = AUTH_TOKEN;
// axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded';
let config = {
// baseURL: process.env.baseURL || process.env.apiUrl || ""
// timeout: 60 * 1000, // Timeout
// withCredentials: true, // Check cross-site Access-Control
};
const _axios = axios.create(config);
_axios.interceptors.request.use(
function(config) {
// Do something before request is sent
return config;
},
function(error) {
// Do something with request error
return Promise.reject(error);
}
);
// Add a response interceptor
_axios.interceptors.response.use(
function(response) {
// Do something with response data
return response;
},
function(error) {
// Do something with response error
return Promise.reject(error);
}
);
Plugin.install = function(Vue, options) {
Vue.axios = _axios;
window.axios = _axios;
Object.defineProperties(Vue.prototype, {
axios: {
get() {
return _axios;
}
},
$axios: {
get() {
return _axios;
}
}
});
};
Vue.use(Plugin);
export default Plugin;

View file

@ -1,11 +1,11 @@
import DeckBuilder from "@/views/DeckBuilder.vue";
import DraftView from "@/views/Draft.vue";
import GameView from "@/views/Game.vue";
import Home from "@/views/Home.vue";
import Lobby from "@/views/Lobby.vue";
import SettingsView from "@/views/Settings.vue";
import Vue from "vue"; import Vue from "vue";
import Router from "vue-router"; import Router from "vue-router";
import Home from "@/views/Home.vue";
import DeckBuilder from "@/views/DeckBuilder.vue";
import GameView from "@/views/Game.vue";
import DraftView from "@/views/Draft.vue";
import Lobby from "@/views/Lobby.vue";
import RoomView from "@/views/Room.vue";
Vue.use(Router); Vue.use(Router);
@ -21,7 +21,10 @@ export default new Router({
{ {
path: "/build", path: "/build",
name: "deck-editor", name: "deck-editor",
component: DeckBuilder component: DeckBuilder,
meta: {
topnav: "Deck Builder"
}
}, },
{ {
path: "/game", path: "/game",
@ -36,12 +39,26 @@ export default new Router({
{ {
path: "/lobby", path: "/lobby",
name: "lobby", name: "lobby",
component: Lobby component: Lobby,
meta: {
topnav: "Lobby"
}
}, },
{ {
path: "/room", path: "/join/:id",
name: "room", name: "lobby-join",
component: RoomView component: Lobby,
meta: {
topnav: "Lobby"
}
},
{
path: "/settings",
name: "settings",
component: SettingsView,
meta: {
topnav: "settings"
}
} }
] ]
}); });

8
src/shims-worker.d.ts vendored Normal file
View file

@ -0,0 +1,8 @@
// typings/custom.d.ts
declare module "worker-loader!*" {
class WebpackWorker extends Worker {
constructor();
}
export default WebpackWorker;
}

View file

@ -0,0 +1,12 @@
import { ActionTree } from "vuex";
import { AppState } from "../types";
import { DraftState } from "./types";
import { Card } from "@/mlpccg";
const actions: ActionTree<DraftState, AppState> = {
pickCard({ commit }, card: Card) {
//TODO
}
};
export default actions;

View file

@ -0,0 +1,12 @@
import { GetterTree } from "vuex";
import { AppState } from "../types";
import { DraftState } from "./types";
import { createPonyheadURL } from "@/mlpccg";
const getters: GetterTree<DraftState, AppState> = {
ponyheadURL(state): string {
return createPonyheadURL(state.picks);
}
};
export default getters;

27
src/store/draft/index.ts Normal file
View file

@ -0,0 +1,27 @@
import { DraftState } from "./types";
import { AppState } from "../types";
import { Module } from "vuex";
const namespaced = true;
import actions from "./actions";
import mutations from "./mutations";
import getters from "./getters";
export const state: DraftState = {
cards: [],
picks: [],
pod: [],
packCount: 0,
currentPack: 0
};
export const draft: Module<DraftState, AppState> = {
namespaced,
state,
actions,
mutations,
getters
};
export default draft;

View file

@ -0,0 +1,37 @@
import { MutationTree } from "vuex";
import { DraftState, PlayerStatus } from "./types";
import { Card } from "@/mlpccg";
const mutations: MutationTree<DraftState> = {
playerPicked(state, payload: { name: string; picked: boolean }) {
const idx = state.pod.findIndex(p => p.name == payload.name);
state.pod[idx].picked = payload.picked;
},
resetPickStatus(state) {
state.pod = state.pod.map(p => ({ ...p, picked: false }));
},
setCardPool(state, pool: Card[]) {
state.cards = pool;
},
setPackInfo(state, payload: { current: number; total: number }) {
state.currentPack = payload.current;
state.packCount = payload.total;
},
addPicks(state, pick: Card) {
state.picks.push(pick);
},
setPod(state, pod: PlayerStatus[]) {
state.pod = pod;
},
resetPicks(state) {
state.picks = [];
}
};
export default mutations;

21
src/store/draft/types.ts Normal file
View file

@ -0,0 +1,21 @@
import { Session } from "@/mlpccg/draft";
import { Card } from "@/mlpccg";
export interface DraftState {
session?: Session;
pod: PlayerStatus[];
cards: Card[];
picks: Card[];
// Multiple pack draft
packCount: number;
currentPack: number;
}
export interface PlayerStatus {
name: string;
isBot: boolean;
isMe: boolean;
picked: boolean;
}

View file

@ -8,6 +8,9 @@ import actions from "./actions";
import mutations from "./mutations"; import mutations from "./mutations";
import getters from "./getters"; import getters from "./getters";
import network from "./network";
import draft from "./draft";
const store: StoreOptions<AppState> = { const store: StoreOptions<AppState> = {
state: { state: {
loaded: false, loaded: false,
@ -17,7 +20,10 @@ const store: StoreOptions<AppState> = {
actions, actions,
mutations, mutations,
getters, getters,
modules: {} modules: {
network,
draft
}
}; };
export default new Vuex.Store<AppState>(store); export default new Vuex.Store<AppState>(store);

View file

@ -0,0 +1,59 @@
import { ChatMessage, Client, LocalClient, NetworkMessage, PeerClient, PeerServer } from "@/network";
import { ActionTree, Commit } from "vuex";
import { AppState } from "../types";
import { ConnectOptions, NetworkState, StartServerOptions } from "./types";
function bindClientEvents(commit: Commit, client: Client) {
client.on("handshake", () => {
commit("playerListChanged", client.players);
});
client.on("player-joined", () => commit("playerListChanged", client.players));
client.on("player-left", () => commit("playerListChanged", client.players));
client.on("rename", () => commit("playerListChanged", client.players));
}
const actions: ActionTree<NetworkState, AppState> = {
startServer({ commit }, options: StartServerOptions) {
const local = new LocalClient(options.playerInfo);
const server = new PeerServer(options.roomInfo, local, options._customPeer);
server.once("open", id => {
commit("serverAssignedID", id);
});
bindClientEvents(commit, local);
commit("becomeServer", { local, server });
},
connect({ commit }, options: ConnectOptions) {
const client = new PeerClient(options.playerInfo, options._customPeer);
commit("becomeClient", { peer: client, id: options.serverID });
client.on("connected", () => {
commit("connectionStatusChanged", "connected");
});
client.on("disconnected", () => {
commit("connectionStatusChanged", "disconnected");
});
client.on("error", err => {
commit("connectionError", err);
});
bindClientEvents(commit, client);
client.connect(options.serverID);
},
sendChatMessage({ commit, dispatch, getters }, message: ChatMessage) {
if (getters.connectionType == "none") {
throw new Error("not connected");
}
dispatch("sendMessage", message);
commit("receivedChatMessage", message);
},
sendMessage({ getters }, message: NetworkMessage) {
if (getters.connectionType == "none") {
throw new Error("not connected");
}
(getters.client as Client).send(message);
}
};
export default actions;

View file

@ -0,0 +1,60 @@
import { Client } from "@/network";
import { GetterTree } from "vuex";
import { AppState } from "../types";
import { NetworkState } from "./types";
const getters: GetterTree<NetworkState, AppState> = {
peerID(state): string | null {
switch (state.peerType) {
case "server":
return state.server.id;
case "client":
return state.peer.id;
}
return null;
},
sessionID(state): string | null {
return state.serverID;
},
client(state): Client | null {
switch (state.peerType) {
case "server":
return state.local;
case "client":
return state.peer;
}
return null;
},
connectionType(state): "client" | "server" | "none" {
return state.peerType;
},
busy(state): boolean {
if (state.peerType == "client") {
if (state.connectionStatus == "connecting") {
return true;
}
}
return false;
},
inRoom(state): boolean {
if (state.peerType == "client") {
return state.connectionStatus == "connected";
}
if (state.peerType == "server") {
return true;
}
return false;
},
players(state): string[] {
return state.players;
}
};
export default getters;

View file

@ -0,0 +1,30 @@
import { NetworkState } from "./types";
import { AppState } from "../types";
import { Module } from "vuex";
import actions from "./actions";
import mutations from "./mutations";
import getters from "./getters";
const namespaced = true;
export const state: NetworkState = {
peerType: "none",
connectionStatus: null,
peer: null,
server: null,
local: null,
serverID: null,
players: [],
chatLog: []
};
export const network: Module<NetworkState, AppState> = {
namespaced,
state,
actions,
mutations,
getters
};
export default network;

View file

@ -0,0 +1,44 @@
import { ChatMessage, LocalClient, PeerClient, PeerServer } from "@/network";
import Vue from "vue";
import { MutationTree } from "vuex";
import { ClientNetworkState, ConnectionStatus, NetworkState, ServerNetworkState } from "./types";
const mutations: MutationTree<NetworkState> = {
becomeServer(state, payload: { local: LocalClient; server: PeerServer }) {
state.peerType = "server";
state.players = [payload.local.name];
(state as ServerNetworkState).local = payload.local;
(state as ServerNetworkState).server = payload.server;
},
becomeClient(state, payload: { peer: PeerClient; id: string }) {
state.peerType = "client";
(state as ClientNetworkState).connectionStatus = "connecting";
(state as ClientNetworkState).peer = payload.peer;
(state as ClientNetworkState).serverID = payload.id;
},
connectionStatusChanged(state, status: ConnectionStatus) {
(state as ClientNetworkState).connectionStatus = status;
},
connectionError(state, error) {
(state as ClientNetworkState).connectionStatus = "error";
(state as ClientNetworkState).connectionError = error;
},
receivedChatMessage(state, message: ChatMessage) {
state.chatLog.push(message);
},
serverAssignedID(state, id: string) {
state.serverID = id;
},
playerListChanged(state, players: string[]) {
Vue.set(state, "players", players);
}
};
export default mutations;

View file

@ -0,0 +1,57 @@
import { ChatMessage, LocalClient, PeerClient, PeerMetadata, PeerServer, RoomInfo } from "@/network";
import Peer from "peerjs";
export type ConnectionStatus =
| "connecting"
| "connected"
| "disconnected"
| "error";
export interface SharedNetworkState {
chatLog: ChatMessage[];
serverID: string | null;
players: string[];
}
export interface NoNetworkState extends SharedNetworkState {
peerType: "none";
connectionStatus: null;
connectionError?: Error;
peer: null;
server: null;
local: null;
}
export interface ClientNetworkState extends SharedNetworkState {
peerType: "client";
connectionStatus: ConnectionStatus;
connectionError?: Error;
peer: PeerClient;
}
export interface ServerNetworkState extends SharedNetworkState {
peerType: "server";
server: PeerServer;
local: LocalClient;
}
export type NetworkState =
| NoNetworkState
| ClientNetworkState
| ServerNetworkState;
export interface StartServerOptions {
roomInfo: RoomInfo;
playerInfo: PeerMetadata;
// Testing utils
_customPeer?: Peer;
}
export interface ConnectOptions {
serverID: string;
playerInfo: PeerMetadata;
// Testing utils
_customPeer?: Peer;
}

11
src/testing/IDBShim.ts Normal file
View file

@ -0,0 +1,11 @@
import Dexie from "dexie";
let init = false;
export function setupIDBShim() {
if (!init) {
const setGlobalVars = require("indexeddbshim");
setGlobalVars(Dexie.dependencies);
init = true;
}
}

View file

@ -1,3 +1,6 @@
export * from "./MockDataConnection"; export * from "./MockDataConnection";
export * from "./MockPeer"; export * from "./MockPeer";
export * from "./MockHelper"; export * from "./MockHelper";
export * from "./EventHook";
export * from "./IDBShim";
export * from "./sync-utils";

View file

@ -0,0 +1,5 @@
export function seconds(ms: number): Promise<void> {
return new Promise(resolve => {
setTimeout(resolve, ms * 1000);
});
}

View file

@ -2,4 +2,4 @@ module.exports = {
env: { env: {
jest: true jest: true
} }
} };

View file

@ -0,0 +1,16 @@
import { Card, createPonyheadURL, cardFullName } from "@/mlpccg";
describe("mlpccg/cards", () => {
test("Card full names are correctly generated in all cases", () => {
const card1 = { Name: "Name", Subname: "" };
const card2 = { Name: "Name1", Subname: "the Name2" };
expect(cardFullName(card1 as Card)).toEqual("Name");
expect(cardFullName(card2 as Card)).toEqual("Name1, the Name2");
});
test("Ponyhead URL is generated correctly", () => {
const cards: any[] = [{ ID: "pr10" }, { ID: "pr12" }, { ID: "pr13" }];
const url = "https://ponyhead.com/deckbuilder?v1code=pr10x1-pr12x1-pr13x1";
expect(createPonyheadURL(cards!)).toEqual(url);
});
});

View file

@ -0,0 +1,52 @@
import CardPicker from "@/components/DeckBuilder/CardPicker.vue";
import CardImage from "@/components/Cards/CardImage.vue";
import { shallowMount, mount } from "@vue/test-utils";
import { seconds } from "@/testing";
// Generate 10 test cards
const testCards = new Array(10)
.fill("test")
.map((t, i) => ({ ID: `${t}${i}` }));
const testSlots = testCards.map(c => ({ data: c, limit: 3, howmany: 1 }));
describe("components/DeckBuilder/CardPicker", () => {
test("CardPicker correctly instances images for each card", () => {
const wrapper = mount(CardPicker, {
propsData: {
rows: 2,
columns: 5,
cards: testSlots
}
});
const cards = wrapper.findAll(".ccgcard");
expect(cards.contains("img")).toBe(true);
});
test("CardImage correctly resolves to an URL after a while", async () => {
const wrapper = mount(CardImage, {
propsData: {
id: "sb1"
}
});
let src = wrapper.attributes("src");
expect(src).toBe(""); // Should be placeholder but it gets stubbed
await seconds(0.5);
src = wrapper.attributes("src");
expect(src).toMatch(/^https?:|^blob:/);
});
test("CardPicker correctly aligns items in a grid", () => {
const wrapper = shallowMount(CardPicker, {
propsData: {
rows: 3,
columns: 5,
cards: testSlots
}
});
const section = wrapper.find(".cardpicker");
const style = section.attributes("style");
expect(style).toMatch(
/grid-template-rows: \S+ \S+ \S+; grid-template-columns: \S+ \S+ \S+ \S+ \S+;/i
);
});
});

View file

@ -0,0 +1,36 @@
import DeckList from "@/components/DeckBuilder/DeckList.vue";
import { shallowMount } from "@vue/test-utils";
import { colorNames } from "@/mlpccg";
// Generate 10 test cards
const testCards = new Array(3).fill("test").map((t, i) => ({
ID: `test${i}`,
Name: `Test name ${i}`,
Subname: `Subname ${i}`,
Type: "Friend",
Element: [colorNames[i]],
Power: i,
Cost: i,
Requirement: { Generosity: i }
}));
const testSlots = testCards.map((c, i) => ({ data: c, limit: 3, howmany: i }));
describe("components/DeckBuilder/DeckList", () => {
test("DeckList correctly detects card info", () => {
const wrapper = shallowMount(DeckList, {
propsData: {
cards: testSlots
}
});
const cards = wrapper.findAll(".ccgcard");
expect(cards.contains(".fullname")).toBe(true);
for (let index = 0; index < testSlots.length; index++) {
const item = cards.at(index);
const card = testSlots[index];
expect(item.find(".amt").text()).toEqual(`${card.howmany}`);
expect(item.find(".fullname .name").text()).toEqual(card.data.Name);
expect(item.find(".fullname .subname").text()).toEqual(card.data.Subname);
//TODO Add more fields check as they are added
}
});
});

View file

@ -0,0 +1,39 @@
import { loadSets, getCards, Database, initDB, cardFullName } from "@/mlpccg";
import { setupIDBShim } from "@/testing";
setupIDBShim();
describe("mlpccg/Database", () => {
beforeAll(async () => {
jest.setTimeout(15000);
await initDB();
await loadSets();
});
test("getCards without a filter returns all the cards", async () => {
expect(Database).toBeTruthy();
const allCards = await Database!.cards.count();
const filtered = await getCards({});
expect(filtered).toHaveLength(allCards);
});
test("getCards with a primary filter filters card correctly", async () => {
expect(Database).toBeTruthy();
const filtered = await getCards({
Types: ["Troublemaker"]
});
for (const card of filtered) {
expect(card.Type).toBe("Troublemaker");
}
});
test("getCards with a secondary filter filters card correctly", async () => {
expect(Database).toBeTruthy();
const filtered = await getCards({
Name: "Rainbow Dash"
});
for (const card of filtered) {
expect(cardFullName(card).indexOf("Rainbow Dash") >= 0).toBeTruthy();
}
});
});

View file

@ -0,0 +1,131 @@
import { setupIDBShim, EventHook } from "@/testing";
import { initDB, loadSets, Database, Card } from "@/mlpccg";
import {
PackBuilder,
spanByRarity,
Cube,
Session,
DraftOptions
} from "@/mlpccg/draft";
setupIDBShim();
const testSessionOptions: DraftOptions = {
type: "booster-draft",
source: "set",
set: "FF",
packs: 2,
players: 4,
spacing: "evenly"
};
describe("mlpccg/draft", () => {
beforeAll(async () => {
jest.setTimeout(30000);
await initDB();
await loadSets();
});
test("Set booster packs are generated correctly", async () => {
expect(Database).toBeTruthy();
const builder = await PackBuilder.fromSet("FF");
const pack = builder.buildPack();
// Check pack size
expect(pack).toHaveLength(12);
const rarities = spanByRarity(pack);
// Check pack distribution
expect(rarities["R"]).toHaveLength(1);
expect(rarities["U"]).toHaveLength(3);
});
test("Cube can load a newline separated card list", async () => {
expect(Database).toBeTruthy();
const cubeCards = ["ff10", "ff11", "ff12", "ff13", "ff14", "ff15"];
const cubeList = cubeCards.join("\n");
const cube = await Cube.fromList(cubeList);
const builder = new PackBuilder(cube.schema());
const pack = builder.buildPack();
// Pack size should only be 6, since there are not enough cards for a 12 cards pack
expect(pack).toHaveLength(6);
// Make sure pack has ALL the cards from the pool, no duplicates
const sortedPack = pack.map(c => c.ID).sort();
expect(sortedPack).toEqual(cubeCards);
});
test("A session can be initialized", async () => {
expect(Database).toBeTruthy();
const session = await Session.create(testSessionOptions);
const hook = new EventHook();
hook.hookEmitter(session, "start", "session-start");
session.assign(["test1", "test2"], () => {
// Do nothing
});
session.start();
await hook.expect("session-start");
});
test("Players receive pick events and can pick cards", async () => {
expect(Database).toBeTruthy();
const session = await Session.create(testSessionOptions);
const hook = new EventHook();
hook.hookEmitter(session, "start", "session-start");
hook.hookEmitter(session, "next-pack", "session-new-pack");
hook.hookEmitter(session, "next-pick", "session-new-pick");
hook.hookEmitter(session, "player-pick", "session-picked");
hook.hookEmitter(session, "draft-over", "session-done");
session.assign(["test1", "test2"], (name, player) => {
player.on("available-picks", cards => {
// setTimeout hack to avoid handlers being called before the rest of the code
setTimeout(() => player.pick(cards[0].ID), 0);
});
});
session.start();
await hook.expect("session-start");
for (let i = 0; i < testSessionOptions.packs; i++) {
await hook.expect("session-new-pack");
for (let j = 0; j < 12; j++) {
for (let p = 0; p < testSessionOptions.players; p++) {
await hook.expect("session-picked");
}
if (i < testSessionOptions.packs - 1) {
await hook.expect("session-new-pick");
}
}
}
await hook.expect("session-done");
});
test("Sessions can load and draft I8PCube files", async () => {
expect(Database).toBeTruthy();
const session = await Session.create({
type: "booster-draft",
source: "i8pcube",
url: "https://mcg.zyg.ovh/cubes/hamchacube.json",
packs: 4,
players: 4,
spacing: "evenly"
});
const hook = new EventHook();
hook.hookEmitter(session, "start", "session-start");
session.assign(["test1"], (_, player) => {
hook.hookEmitter(player, "available-picks", "got-cards");
});
session.start();
await hook.expect("session-start");
await hook.expect("got-cards", 1000, (cards: Card[]) => {
expect(cards).toHaveLength(12);
// Check for 2 or more multicolor cards
const multicolor = cards.filter(
c =>
c.Element.length > 1 ||
(c.Requirement && Object.keys(c.Requirement).length > 1)
);
expect(multicolor.length).toBeGreaterThanOrEqual(2);
// Check for 2 or more entry cards
const entry = cards.filter(
c => !c.Requirement || c.Requirement.length < 1
);
expect(entry.length).toBeGreaterThanOrEqual(2);
});
});
});

View file

@ -1,6 +1,5 @@
import { MockHelper } from "@/testing"; import { MockHelper, EventHook } from "@/testing";
import { NetworkMessage, LocalClient, ChatMessage } from "@/network"; import { NetworkMessage, LocalClient, ChatMessage } from "@/network";
import { EventHook } from "@/testing/EventHook";
const sampleRoom = () => ({ const sampleRoom = () => ({
max_players: 3, max_players: 3,

View file

@ -1,14 +1,479 @@
<template> <template>
<section class="deckbuilder"></section> <section class="deckbuilder">
<TopNav class="topnav" />
<section class="cardlist">
<section class="filters">
<div class="row">
<b-input
@input="textChanged"
v-model="nameFilter"
placeholder="Search name"
></b-input>
<div class="colorfilter" v-for="color in colors" :key="color">
<img
@click="toggleFilter(elementFilters, color)"
:class="filterIconClass(elementFilters, color)"
:src="elementIconURL(color)"
/>
</div>
<div class="divider" />
<div class="typefilter" v-for="type in types" :key="type">
<img
@click="toggleFilter(typeFilters, type)"
:class="filterIconClass(typeFilters, type)"
:src="typeIconURL(type)"
/>
</div>
</div>
<div class="row">
<b-input
@input="textChanged"
v-model="ruleFilter"
placeholder="Search rule text"
></b-input>
<div class="setfilter" v-for="set in sets" :key="set">
<img
@click="toggleFilter(setFilters, set)"
:class="filterIconClass(setFilters, set)"
:src="setIconURL(set)"
/>
</div>
</div>
</section>
<section class="cards">
<div @click="prevPage" :class="canGoPrev ? 'prev' : 'prev unavailable'">
<img src="../assets/images/deckbuilder/navarrow.svg" />
</div>
<CardPicker
@picked="cardPicked"
:columns="columns"
:rows="rows"
:cards="currentPage"
/>
<div @click="nextPage" :class="canGoNext ? 'next' : 'next unavailable'">
<img src="../assets/images/deckbuilder/navarrow.svg" />
</div>
</section>
</section>
<section class="decklist">
<header>
<h1>{{ deckname }}</h1>
<nav class="buttons">
<b-button @click="exportToPonyhead" size="is-small deck-btn"
>Ponyhead</b-button
>
</nav>
</header>
<DeckList :cards="decklist" @removed="cardRemoved" />
</section>
</section>
</template> </template>
<style lang="scss" scoped></style> <style lang="scss" scoped>
@import "@/assets/scss/_variables.scss";
.deckbuilder {
background: url("../assets/images/backgrounds/deckbuilderbg.webp") center;
background-repeat: no-repeat;
background-size: cover;
height: 100vh;
display: grid;
grid-template-columns: 3fr minmax(250px, 1fr);
grid-template-rows: auto 1fr;
}
.topnav {
grid-column: 1/3;
}
.cardlist {
display: grid;
grid-column: 1;
grid-template-rows: 110px 1fr;
}
.filters {
display: flex;
flex-direction: column;
padding: 5px;
.row {
flex: 1;
display: flex;
align-items: center;
* {
margin: 5px;
}
}
}
.cards {
display: grid;
grid-template-columns: 5% 1fr 5%;
column-gap: 10px;
}
.decklist {
padding: 10px;
padding-top: 15px;
grid-column: 2;
header {
padding-left: 1rem;
color: $primary-text;
font-family: $fantasy;
h1 {
font-size: 20pt;
font-weight: 700;
margin-bottom: 5px;
}
}
}
.colorfilter,
.setfilter,
.typefilter {
cursor: pointer;
img {
opacity: 0.4;
&:hover {
opacity: 0.7;
}
&.selected {
opacity: 1;
}
}
}
.colorfilter {
width: 42px;
height: 42px;
}
.setfilter {
width: 32px;
height: 32px;
}
.typefilter {
width: 42px;
height: 42px;
}
.prev,
.next {
opacity: 0.5;
display: flex;
cursor: pointer;
&:hover {
opacity: 0.9;
}
&.unavailable {
opacity: 0.1;
cursor: not-allowed;
}
}
.next img {
transform: scaleX(-1);
}
.divider {
width: 10px;
height: 10px;
}
.deck-btn {
padding: 0 10px;
padding-bottom: 2px;
}
</style>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-property-decorator"; import { Component, Vue } from "vue-property-decorator";
import DeckList from "@/components/DeckBuilder/DeckList.vue";
import CardPicker from "@/components/DeckBuilder/CardPicker.vue";
import TopNav from "@/components/Navigation/TopNav.vue";
import {
Card,
CardFilter,
CardSlot,
getCards,
allSets,
cardFullName,
createPonyheadURL,
multiElemStr,
typeIndex,
rarityIndex,
colorNames,
typeNames,
cardLimit
} from "@/mlpccg";
// Sort function for sorting cards
function sortByColor(a: Card, b: Card) {
const typeA = typeIndex(a.Type);
const typeB = typeIndex(b.Type);
if (typeA != typeB) {
return typeA - typeB;
}
// Same types, filter by primary element
switch (a.Type) {
case "Friend":
case "Mane Character":
{
const elemA = multiElemStr(a.Element);
const elemB = multiElemStr(b.Element);
if (elemA > elemB) {
return 1;
}
if (elemB > elemA) {
return -1;
}
}
break;
case "Problem":
if (a.ProblemRequirement && b.ProblemRequirement) {
const preqA = multiElemStr(Object.keys(a.ProblemRequirement));
const preqB = multiElemStr(Object.keys(b.ProblemRequirement));
if (preqA > preqB) {
return 1;
}
if (preqB > preqA) {
return -1;
}
}
break;
case "Event":
case "Resource":
if (a.Requirement && b.Requirement) {
const reqA = multiElemStr(Object.keys(a.Requirement));
const reqB = multiElemStr(Object.keys(b.Requirement));
if (reqA > reqB) {
return 1;
}
if (reqB > reqA) {
return -1;
}
}
break;
}
// Filter by power
if (a.Power && b.Power) {
if (a.Power != b.Power) {
return a.Power - b.Power;
}
}
// Filter by Rarity (not the pony)
const rarityA = rarityIndex(a.Rarity);
const rarityB = rarityIndex(b.Rarity);
if (rarityA != rarityB) {
return rarityA - rarityB;
}
const nameA = cardFullName(a);
const nameB = cardFullName(b);
if (nameA > nameB) {
return 1;
}
if (nameB > nameA) {
return -1;
}
return 0;
}
@Component({ @Component({
components: {} components: {
TopNav,
DeckList,
CardPicker
}
}) })
export default class DeckBuilder extends Vue {} export default class DeckBuilder extends Vue {
// Picked/filtered cards
private decklist!: CardSlot[];
private filtered!: Card[];
// Names
private colors!: string[];
private sets!: string[];
private types!: string[];
// Card picker size
private rows!: number;
private columns!: number;
// User Filters
private nameFilter!: string;
private ruleFilter!: string;
private setFilters!: string[];
private elementFilters!: string[];
private typeFilters!: string[];
// Decklist options
private deckname!: string;
// Navigation
private offset!: number;
private data() {
return {
decklist: [],
filtered: [],
offset: 0,
rows: 2,
columns: 5,
nameFilter: "",
ruleFilter: "",
setFilters: [],
elementFilters: [],
typeFilters: [],
colors: colorNames,
sets: allSets.slice(0, -1),
types: typeNames,
deckname: "Unnamed deck"
};
}
private mounted() {
this.applyFilters();
}
private async applyFilters() {
let filters: CardFilter = {};
if (this.setFilters.length > 0) {
filters.Sets = this.setFilters;
}
if (this.elementFilters.length > 0) {
filters.Elements = this.elementFilters;
}
if (this.typeFilters.length > 0) {
filters.Types = this.typeFilters;
}
if (this.nameFilter.length > 0) {
filters.Name = this.nameFilter;
}
if (this.ruleFilter.length > 0) {
filters.Rules = this.ruleFilter;
}
const filtered = await getCards(filters);
this.filtered = filtered.sort(sortByColor);
this.offset = 0;
}
private get itemsPerPage() {
return this.columns * this.rows;
}
private get currentPage(): CardSlot[] {
return this.filtered
.slice(this.offset, this.offset + this.itemsPerPage)
.map(card => {
const res = this.decklist.find(c => c.data.ID == card.ID);
if (res) {
return res;
}
return {
data: card,
limit: cardLimit(card.Type),
howmany: 0
};
});
}
private elementIconURL(element: string): string {
return require(`../assets/images/elements/${element.toLowerCase()}.webp`);
}
private setIconURL(set: string): string {
return require(`../assets/images/sets/${set.toUpperCase()}.webp`);
}
private typeIconURL(type: string): string {
let urltype = type.toLowerCase();
if (urltype == "mane character") {
urltype = "mane-char";
}
return require(`../assets/images/cardtypes/${urltype}.webp`);
}
private filterIconClass(filter: string[], key: string) {
return {
selected: filter.includes(key)
};
}
private toggleFilter(filter: string[], key: string) {
const idx = filter.indexOf(key);
if (idx >= 0) {
filter.splice(idx, 1);
} else {
filter.push(key);
}
this.applyFilters();
}
private textChanged() {
this.applyFilters();
}
private get canGoPrev(): boolean {
return this.offset > 0;
}
private get canGoNext(): boolean {
return this.offset + this.itemsPerPage < this.filtered.length;
}
private prevPage() {
if (!this.canGoPrev) {
return;
}
this.offset = Math.max(0, this.offset - this.itemsPerPage);
}
private nextPage() {
if (!this.canGoNext) {
return;
}
this.offset = Math.min(
this.filtered.length - this.itemsPerPage,
this.offset + this.itemsPerPage
);
}
private cardPicked(card: Card) {
// Check if card is already in
const idx = this.decklist.findIndex(c => c.data.ID == card.ID);
if (idx >= 0) {
const deckitem = this.decklist[idx];
deckitem.howmany += 1;
Vue.set(this.decklist, idx, deckitem);
} else {
this.decklist.push({
data: card,
limit: cardLimit(card.Type),
howmany: 1
});
}
}
private exportToPonyhead() {
const url = createPonyheadURL(this.decklist.map(c => c.data));
window.open(url, "_blank");
}
private cardRemoved(card: CardSlot) {
const idx = this.decklist.findIndex(c => c.data.ID == card.data.ID);
if (idx < 0) {
throw new Error("Removing card that isn't in the deck?");
}
const deckitem = this.decklist[idx];
deckitem.howmany -= 1;
if (deckitem.howmany <= 0) {
this.decklist.splice(idx, 1);
} else {
Vue.set(this.decklist, idx, deckitem);
}
}
}
</script> </script>

View file

@ -1,23 +1,118 @@
<template> <template>
<section class="draftview"> <section class="draftview">
<section class="playerlist"> <section class="playerlist">
<b>Players</b> <header>
<h2>Players</h2>
</header>
<section class="players">
<article
v-for="(player, i) in playerStatus"
:key="player.name + i"
:class="playerClass(player)"
>
<div class="icon">
<b-icon v-if="player.isBot" icon="robot" size="is-small" />
<b-icon v-else icon="account-box" size="is-small" />
</div>
<div class="name">{{ player.name }}</div>
</article>
</section>
</section>
<section class="pool">
<header>
<h2>
Available picks (Pack
<span class="pack-number">{{ packNumber }}</span> of
<span class="pack-count">{{ packCount }}</span
>)
</h2>
</header>
<CardPicker
@picked="cardPicked"
:columns="columns"
:rows="rows"
:cards="currentPicks"
class="picker"
/>
</section>
<section class="cardlist">
<header>
<h2>Cards</h2>
</header>
<DeckList :cards="pickedCards" />
</section> </section>
<section class="pool"><b>Card pool</b></section>
<section class="cardlist"><b>Cards</b></section>
</section> </section>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
@import "@/assets/scss/_variables.scss";
$player-not-picked: $red;
$player-picked: $green;
$player-me: $purple;
$border-opacity: 0.6;
.draftview { .draftview {
background: url("../assets/images/backgrounds/draftbg.webp") center;
display: grid; display: grid;
height: 100vh; height: 100vh;
grid-gap: 10px; gap: 10px;
grid-template-columns: 200px 1fr 250px; grid-template-columns: minmax(200px, 1fr) 3fr minmax(250px, 1fr);
section { & > section {
padding: 10px 20px;
grid-row: 1; grid-row: 1;
border: 1px solid #555;
} }
header > h2 {
font-family: $fantasy;
font-weight: 600;
font-size: 2.5vh;
padding: 1vh;
}
}
.playerlist {
.players {
display: flex;
flex-flow: column wrap;
max-height: 100%;
}
.player {
border-radius: 3px;
margin: 2px;
padding: 2px;
border: 2px solid rgba($player-not-picked, $border-opacity);
display: flex;
color: $red;
align-items: center;
user-select: all;
&.picked,
&.bot {
color: $player-picked;
border-color: rgba($player-picked, $border-opacity);
}
&.me {
border-color: rgba($player-me, $border-opacity);
}
&.me::after {
content: " (me)";
font-size: 10pt;
margin-left: 5px;
color: $player-me;
}
}
}
.pool {
display: flex;
flex-direction: column;
.picker {
flex: 1;
}
}
.pack-number {
color: $primary;
} }
@media (max-width: 800px) { @media (max-width: 800px) {
@ -54,9 +149,95 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-property-decorator"; import { Component, Vue } from "vue-property-decorator";
import { getCards, CardSlot, cardLimit, Card } from "@/mlpccg";
import CardPicker from "@/components/DeckBuilder/CardPicker.vue";
import DeckList from "@/components/DeckBuilder/DeckList.vue";
import { PlayerStatus } from "@/store/draft/types";
@Component({ @Component({
components: {} components: {
CardPicker,
DeckList
}
}) })
export default class DraftView extends Vue {} export default class DraftView extends Vue {
// Card picker size
private rows!: number;
private columns!: number;
// Draft data
private packNumber!: number;
private packCount!: number;
private currentPicks!: CardSlot[];
private pickedCards!: CardSlot[];
private data() {
return {
packNumber: 1,
packCount: 4,
rows: 3,
columns: 4,
currentPicks: [],
pickedCards: []
};
}
private mounted() {
this.fillcards();
}
private async fillcards() {
const cards = await getCards({ Sets: ["FF"] });
this.currentPicks = cards.slice(0, 12).map(c => ({
data: c,
howmany: 1,
limit: 0
}));
}
private cardPicked(card: Card) {
// Check if card is already in
const idx = this.pickedCards.findIndex(c => c.data.ID == card.ID);
if (idx >= 0) {
const deckitem = this.pickedCards[idx];
deckitem.howmany += 1;
Vue.set(this.pickedCards, idx, deckitem);
} else {
this.pickedCards.push({
data: card,
limit: cardLimit(card.Type),
howmany: 1
});
}
const origIdx = this.currentPicks.findIndex(c => c.data.ID == card.ID);
this.currentPicks.splice(origIdx, 1);
}
private get playerStatus(): PlayerStatus[] {
return [
"Player1",
"bot",
"bot",
"Player2",
"bot",
"bot",
"Hamcha",
"bot"
].map(s => ({
name: s,
isBot: s == "bot",
isMe: s == "Hamcha",
picked: s == "b" || s == "Player2"
}));
}
private playerClass(player: PlayerStatus) {
return {
player: true,
me: player.isMe,
bot: player.isBot,
picked: player.picked
};
}
}
</script> </script>

View file

@ -1,12 +1,140 @@
<template> <template>
<section class="home"></section> <section class="home">
<section class="hero is-medium is-primary is-bold">
<div class="hero-body">
<div class="container has-text-centered">
<h1 class="title">
{{ projectName }}
</h1>
<h2 class="subtitle">
An unofficial, fan-made MLP:CCG simulator
</h2>
<br />
<b-button
tag="router-link"
to="/lobby"
class="is-primary spaced"
inverted
outlined
>Play with people</b-button
>
<b-button
tag="router-link"
to="/build"
class="is-primary spaced"
inverted
outlined
>Build a deck</b-button
>
</div>
</div>
</section>
<section class="section">
<div class="container is-widescreen landing-content">
<div class="columns">
<div class="column">
<section class="has-text-centered">
<div class="title is-4">
<strong>Web-based</strong>
</div>
</section>
<div class="content">
The entire client runs in your browser. No downloads necessary!
</div>
</div>
<div class="column">
<section class="has-text-centered">
<div class="title is-4">
<strong>Constructed</strong>
<span class="behind fantasy"> & </span>
<strong>Limited</strong>
</div>
</section>
<div class="content">
You can build decks or draft them with friends. You can even do
cube drafts!
</div>
</div>
<div class="column">
<section class="has-text-centered">
<div class="title is-4">
<strong>Peer-to-Peer</strong>
</div>
</section>
<div class="content">
The entire client uses WebRTC for P2P communications. No
registration required to play!
</div>
</div>
</div>
</div>
</section>
<footer class="footer">
<div class="container is-widescreen">
<div class="content has-text-centered">
<p>
<strong>{{ projectName }}</strong> is not affiliated with Hasbro in
any way.<br />
Come look at the
<a href="https://git.fromouter.space/mcg/mlpcardgame"
>source code</a
>
(ISC licensed)!
</p>
<p>MCG Version {{ projectVersion }}</p>
</div>
</div>
</footer>
</section>
</template> </template>
<style lang="scss" scoped>
@import "@/assets/scss/_variables.scss";
.home {
user-select: text;
}
.behind {
opacity: 0.8;
}
.fantasy {
font-family: $fantasy;
}
.landing-content .column {
border: 1px solid $grey;
border-radius: 10px;
margin: 10px;
.title {
padding-bottom: 10px;
border-bottom: 1px solid rgba($white, 0.2);
}
.content {
padding: 10px;
font-size: 11pt;
}
}
.spaced {
margin: 5px;
}
</style>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-property-decorator"; import { Component, Vue } from "vue-property-decorator";
declare const GIT_DESCRIBE: { raw: string };
let versionString: string = GIT_DESCRIBE
? GIT_DESCRIBE.raw
: require("@/../package.json").version;
@Component({ @Component({
components: {} components: {}
}) })
export default class Home extends Vue {} export default class Home extends Vue {
private projectName: string = "MLPCARDGAME";
private projectVersion: string = versionString;
}
</script> </script>

View file

@ -1,14 +1,374 @@
<template> <template>
<section class="lobby"></section> <section class="lobby">
<TopNav />
<section v-if="!inRoom" class="body">
<section id="info">
<b-field label="Your name">
<b-input :disabled="busy" v-model="playerName"></b-input>
</b-field>
</section>
<section id="join">
<header>
<h1>Join someone's session</h1>
</header>
<b-field label="Session ID" class="only-full">
<b-input :disabled="busy" v-model="joinSessionID"></b-input>
</b-field>
<div class="center submit only-full">
<b-button
type="is-primary"
@click="join"
class="wide"
:disabled="busy || !canJoin"
>
Join
</b-button>
</div>
<div class="only-mobile">
<b-field class="full">
<b-input
:disabled="busy"
placeholder="Session ID"
v-model="joinSessionID"
></b-input>
<p class="control">
<b-button
type="is-primary"
@click="join"
:disabled="busy || !canJoin"
>
Join
</b-button>
</p>
</b-field>
</div>
</section>
<section id="host">
<header>
<h1>Host a new session</h1>
</header>
<div class="columns">
<div class="column">
<b-field label="Max players">
<b-numberinput
:disabled="busy"
controls-position="compact"
v-model="hostMaxPlayers"
min="2"
max="8"
></b-numberinput>
</b-field>
</div>
<div class="column">
<b-field label="Password">
<b-input :disabled="busy" v-model="hostPassword"></b-input>
</b-field>
</div>
</div>
<div class="center">
<b-button
:disabled="busy"
type="is-primary"
@click="create"
class="wide"
>
Create
</b-button>
</div>
</section>
</section>
<section class="room" v-else>
<section class="info">
Invite your friends:
<span class="selectable"
><a :href="inviteLink">{{ inviteLink }}</a></span
>
</section>
<section class="chat"></section>
<section class="players">
<header>Players</header>
<ul>
<li class="selectable" v-for="player in players" :key="player">
{{ player }}
</li>
</ul>
</section>
<section class="player-options">
<b-field>
<b-input :disabled="busy" v-model="wantedName"></b-input>
<p class="control">
<b-button
@click="changeName"
type="is-primary"
:disabled="!nameAvailable"
>Change name</b-button
>
</p>
</b-field>
<b-button type="is-danger">Leave room</b-button>
</section>
</section>
</section>
</template> </template>
<style lang="scss" scoped></style> <style lang="scss" scoped>
@import "@/assets/scss/_variables.scss";
.lobby {
background: url("../assets/images/backgrounds/menubg.webp") center;
background-repeat: no-repeat;
background-size: cover;
height: 100vh;
display: flex;
flex-flow: column;
}
.body {
flex: 1;
display: grid;
padding: 10px;
padding-top: 0;
grid-template: 150px 1fr / 1fr 1fr;
#info {
grid-row: 1;
grid-column: 1 / end;
}
#join,
#host {
grid-row: 2;
}
#join {
grid-column: 2;
}
#host {
grid-column: 1;
}
section {
margin: 10px;
border: 1px solid rgba($white, 20%);
border-radius: 10px;
padding: 20px;
header {
font-family: $fantasy;
h1 {
text-align: center;
font-size: 18pt;
}
margin-bottom: 20px;
}
}
}
.wide {
min-width: 30%;
margin: 10px auto;
}
.full {
width: 100%;
margin: 10px;
}
.center {
width: 100%;
text-align: center;
}
.full-btn {
flex: 1;
:global(.button) {
width: 100%;
}
}
.room {
padding: 10px 20px;
margin: 10px;
border: 1px solid rgba($white, 20%);
border-radius: 10px;
display: grid;
grid-template: 50px 1fr / 200px 1fr 300px;
.info {
grid-column: 1 / max;
grid-row: 1;
}
.players {
header {
font-weight: bold;
margin-bottom: 5px;
}
grid-row: 2 / max;
grid-column: 1;
}
.chat {
grid-row: 2 / max;
grid-column: 2;
}
.player-options {
grid-row: 2 / max;
grid-column: 3;
}
}
.only-mobile {
display: none;
}
.selectable {
user-select: all;
}
@media (max-width: 500px) {
.only-full {
display: none;
}
.only-mobile {
display: inherit;
}
.body {
display: flex;
flex-direction: column;
section {
padding: 10px;
header h1 {
font-size: 14pt;
}
}
}
.room {
display: flex;
flex-flow: column;
& > * {
padding: 10px;
}
}
}
</style>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-property-decorator"; import { Component, Vue } from "vue-property-decorator";
import TopNav from "@/components/Navigation/TopNav.vue";
import { StartServerOptions, ConnectOptions } from "@/store/network/types";
import { Action, Getter } from "vuex-class";
import { Client, NetworkMessage } from "@/network";
const networkNS = { namespace: "network" };
@Component({ @Component({
components: {} components: {
TopNav
}
}) })
export default class Lobby extends Vue {} export default class Lobby extends Vue {
private playerName!: string;
private hostMaxPlayers!: number;
private hostPassword!: string;
private joinSessionID!: string;
private wantedName!: string;
@Action("startServer", networkNS)
private startServer!: (options: StartServerOptions) => void;
@Action("sendMessage", networkNS)
private sendMessage!: (message: NetworkMessage) => void;
@Action("connect", networkNS)
private connect!: (options: ConnectOptions) => void;
@Getter("inRoom", networkNS)
private inRoom!: boolean;
@Getter("busy", networkNS)
private busy!: boolean;
@Getter("sessionID", networkNS)
private sessionID!: string | null;
@Getter("players", networkNS)
private players!: string[];
private data() {
const playerName =
"Guest-" +
Math.random()
.toString()
.slice(2, 8);
return {
playerName,
hostMaxPlayers: 8,
hostPassword: "",
joinSessionID: "",
wantedName: playerName
};
}
private mounted() {
if ("id" in this.$route.params) {
this.joinSessionID = this.$route.params.id;
this.join();
}
}
private async create() {
this.wantedName = this.playerName;
this.startServer({
playerInfo: {
name: this.playerName
},
roomInfo: {
max_players: this.hostMaxPlayers,
password: this.hostPassword
}
});
}
private async join() {
this.wantedName = this.playerName;
this.connect({
serverID: this.joinSessionID,
playerInfo: {
name: this.playerName
}
});
}
private async changeName() {
this.sendMessage({
kind: "rename",
oldname: this.playerName,
newname: this.wantedName
});
}
private get canJoin(): boolean {
return this.joinSessionID != "";
}
private get inviteLink(): string {
let subpath = "";
const joinIndex = location.pathname.indexOf("/join");
if (joinIndex > 0) {
subpath = location.pathname.substring(0, joinIndex);
}
const lobbyIndex = location.pathname.indexOf("/lobby");
if (lobbyIndex > 0) {
subpath = location.pathname.substring(0, lobbyIndex);
}
return `${location.origin}${subpath}/join/${this.sessionID}`;
}
private get nameAvailable(): boolean {
return this.wantedName != "" && !this.players.includes(this.wantedName);
}
}
</script> </script>

View file

@ -1,14 +0,0 @@
<template>
<section class="room"></section>
</template>
<style lang="scss" scoped></style>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
@Component({
components: {}
})
export default class RoomView extends Vue {}
</script>

191
src/views/Settings.vue Normal file
View file

@ -0,0 +1,191 @@
<template>
<section class="settings">
<TopNav class="top" />
<section class="settings-box download">
<header>
<h1>Storage settings</h1>
</header>
<div class="rows">
<article>
<div class="name">
Card image source
</div>
<div class="value">
{{ imageSource }}
</div>
<div class="actions">
<b-button
class="is-primary"
@click="downloadImages"
:disabled="cardImageSource == 'local'"
>Download images</b-button
>
</div>
</article>
</div>
</section>
<b-modal :active.sync="isDownloading" :can-cancel="false">
<div class="modal-card" style="width: auto">
<header class="modal-card-head">
<p class="modal-card-title">
{{ downloadStatus }}
</p>
</header>
<section class="modal-card-body">
<b-progress
size="is-large"
type="is-danger"
v-if="downloadProgress"
:max="downloadProgress.total"
:value="downloadProgress.progress"
show-value
>
{{ downloadProgressString }}
</b-progress>
</section>
</div>
</b-modal>
</section>
</template>
<style lang="scss" scoped>
@import "@/assets/scss/_variables.scss";
.settings {
display: grid;
}
.top {
grid-column: 1 / end;
}
.settings-box {
margin: 20px;
padding: 20px;
background: rgba($white, 0.1);
border-radius: 6px;
header {
h1 {
font-family: $fantasy;
font-size: 17pt;
}
}
.rows {
display: flex;
flex-direction: column;
article {
margin: 10px 0;
font-size: 12pt;
display: flex;
align-items: center;
& > div {
margin: 5px;
}
.value {
border: 1px solid rgba($black, 0.4);
border-radius: 3px;
padding: 5px 10px;
}
}
}
}
</style>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import TopNav from "@/components/Navigation/TopNav.vue";
import { TaskRunner } from "@/workers";
import { cardImageSource, refreshCardSource } from "@/mlpccg";
@Component({
components: { TopNav }
})
export default class SettingsView extends Vue {
private cardImageSource!: "local" | "remote";
private downloadState!: "starting" | "download" | "extract" | null;
private downloadProgress!: { progress: number; total: number } | null;
private downloadImages() {
this.downloadState = "starting";
const worker = new TaskRunner("downloadCardImages");
worker.on("dl-progress", progress => {
if (this.downloadState != "download") {
this.downloadState = "download";
}
this.downloadProgress = progress;
});
worker.on("ex-progress", progress => {
if (this.downloadState != "extract") {
this.downloadState = "extract";
}
this.downloadProgress = progress;
});
worker.on("finish", async _ => {
this.downloadState = null;
await refreshCardSource();
this.cardImageSource = cardImageSource();
});
}
private data() {
return {
cardImageSource: cardImageSource(),
downloadState: null,
downloadProgress: null
};
}
private get imageSource() {
switch (this.cardImageSource) {
case "local":
return "Local saved copy";
case "remote":
return "Remote server";
}
return "Unknown";
}
private get isDownloading(): boolean {
return this.downloadState !== null;
}
private get downloadStatus(): string {
switch (this.downloadState) {
case "starting":
return "Starting download...";
case "download":
return `Downloading image archive (${Math.round(
this.downloadProgress!.total / 10485.76
) / 100} MB)`;
case "extract":
return `Extracting images`;
}
return "";
}
private get downloadProgressString(): string {
if (!this.downloadProgress) {
return "";
}
let current = "";
let total = "";
if (this.downloadState == "extract") {
current = `${(this.downloadProgress.progress / 2) | 0}`;
total = `${(this.downloadProgress.total / 2) | 0}`;
} else {
const currentNum =
Math.round(this.downloadProgress.progress / 10485.76) / 100;
current = currentNum.toString().padEnd(currentNum < 10 ? 4 : 5, "0");
const totalNum = Math.round(this.downloadProgress.total / 10485.76) / 100;
total = totalNum.toString().padEnd(totalNum < 10 ? 4 : 5, "0") + " MB";
}
const percent = Math.round(
(this.downloadProgress.progress / this.downloadProgress.total) * 100
);
return `${percent}% (${current}/${total})`;
}
}
</script>

1
src/workers/index.ts Normal file
View file

@ -0,0 +1 @@
export * from "./runner";

17
src/workers/runner.ts Normal file
View file

@ -0,0 +1,17 @@
import EventEmitter from "eventemitter3";
export class TaskRunner extends EventEmitter {
public class: any;
public instance: Worker;
constructor(taskName: string) {
super();
this.class = require(`worker-loader!@/workers/tasks/${taskName}`);
this.instance = new this.class() as Worker;
this.instance.addEventListener("error", ev => this.emit("error", ev));
this.instance.addEventListener("message", ev => {
const message = JSON.parse(ev.data);
this.emit(message.type, message.data);
});
}
}

View file

@ -0,0 +1,72 @@
import { Database, initDB } from "@/mlpccg";
import axios from "axios";
import JSZip from "jszip";
import { send, runAsync } from "../worker-utils";
async function downloadImages() {
if (!Database) {
await initDB();
}
let table = Database!.images;
const itemcount = await table.count();
if (itemcount > 1900) {
// DB already filled, exit early
return "already-done";
}
const zipdata = await axios({
url: "https://mcg.zyg.ovh/cards.zip",
responseType: "blob",
onDownloadProgress: (progressEvent: ProgressEvent) => {
send("dl-progress", {
progress: progressEvent.loaded,
total: progressEvent.total
});
}
});
const zipfile = await JSZip.loadAsync(zipdata.data);
const cards = zipfile.folder("Cards");
let loadingState = 0;
let totalLoading = 0;
cards.forEach(async () => {
totalLoading += 2;
});
let waitgroup = new Promise(resolve => {
let timer = setInterval(() => {
if (loadingState >= totalLoading) {
clearInterval(timer);
resolve();
}
}, 1000);
});
cards.forEach(async (filename, filedata) => {
const data = await filedata.async("blob");
loadingState += 1;
send("ex-progress", {
progress: loadingState,
total: totalLoading
});
const result = await table.put({ id: filename, image: data });
loadingState += 1;
send("ex-progress", {
progress: loadingState,
total: totalLoading
});
});
await waitgroup;
return "downloaded";
}
runAsync(async () => {
try {
return await downloadImages();
} catch (e) {
console.error(e);
return new Error(e.message);
}
});

View file

@ -0,0 +1,9 @@
export function send(type: string, data?: any) {
const ctx: Worker = self as any;
ctx.postMessage(JSON.stringify({ type, data }));
}
export async function runAsync(fn: () => Promise<any>) {
const val = await fn();
send("finish", val);
}

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