Compare commits

..

1 commit

Author SHA1 Message Date
a6ecace783
Update build to allow custom path (or use relative paths)
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2019-09-10 12:19:37 +02:00
103 changed files with 275 additions and 4284 deletions

View file

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

View file

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

1
.gitignore vendored
View file

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

View file

@ -4,58 +4,6 @@
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
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"],
transform: {
"^.+\\.vue$": "vue-jest",
".+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2|webp)$":
".+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$":
"jest-transform-stub",
"^.+\\.tsx?$": "ts-jest"
},

View file

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

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

View file

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

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

View file

@ -1,13 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

View file

@ -3,44 +3,32 @@
// Colors
$black: hsl(0, 0%, 4%) !default;
$black-bis: hsl(0, 0%, 7%) !default;
$black-ter: hsl(0, 0%, 14%) !default;
$black: hsl(0, 0%, 4%) !default;
$black-bis: hsl(0, 0%, 7%) !default;
$black-ter: hsl(0, 0%, 14%) !default;
$grey-darker: hsl(0, 0%, 21%) !default;
$grey-dark: hsl(0, 0%, 29%) !default;
$grey: hsl(0, 0%, 48%) !default;
$grey-light: hsl(0, 0%, 71%) !default;
$grey-darker: hsl(0, 0%, 21%) !default;
$grey-dark: hsl(0, 0%, 29%) !default;
$grey: hsl(0, 0%, 48%) !default;
$grey-light: hsl(0, 0%, 71%) !default;
$grey-lighter: hsl(0, 0%, 86%) !default;
$white-ter: hsl(0, 0%, 96%) !default;
$white-bis: hsl(0, 0%, 98%) !default;
$white: hsl(0, 0%, 100%) !default;
$white-ter: hsl(0, 0%, 96%) !default;
$white-bis: hsl(0, 0%, 98%) !default;
$white: hsl(0, 0%, 100%) !default;
$orange: hsl(14, 100%, 53%) !default;
$yellow: hsl(48, 100%, 67%) !default;
$green: hsl(141, 71%, 48%) !default;
$turquoise: hsl(171, 100%, 41%) !default;
$cyan: hsl(204, 86%, 53%) !default;
$blue: hsl(217, 71%, 53%) !default;
$purple: hsl(271, 100%, 71%) !default;
$red: hsl(348, 100%, 61%) !default;
$orange: hsl(14, 100%, 53%) !default;
$yellow: hsl(48, 100%, 67%) !default;
$green: hsl(141, 71%, 48%) !default;
$turquoise: hsl(171, 100%, 41%) !default;
$cyan: hsl(204, 86%, 53%) !default;
$blue: hsl(217, 71%, 53%) !default;
$purple: hsl(271, 100%, 71%) !default;
$red: hsl(348, 100%, 61%) !default;
// 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;
$render-mode: optimizeLegibility !default;
@ -65,11 +53,11 @@ $gap: 32px !default;
// 960, 1152, and 1344 have been chosen because they are divisible by both 12 and 16
$tablet: 769px !default;
// 960px container + 4rem
$desktop: 960px+(2 * $gap) !default;
$desktop: 960px + (2 * $gap) !default;
// 1152px container + 4rem
$widescreen: 1152px+(2 * $gap) !default;
$widescreen: 1152px + (2 * $gap) !default;
// 1344px container + 4rem;
$fullhd: 1344px+(2 * $gap) !default;
$fullhd: 1344px + (2 * $gap) !default;
// Miscellaneous
@ -84,6 +72,7 @@ $speed: 86ms !default;
$variable-columns: true !default;
// The default Bulma derived variables are declared below
$primary: $turquoise !default;
@ -161,10 +150,3 @@ $size-small: $size-7 !default;
$size-normal: $size-6 !default;
$size-medium: $size-5 !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,8 +4,6 @@
@import "~bulma/sass/utilities/derived-variables";
@import "~bulma";
@import "~buefy/src/scss/buefy";
@import "dark";
@import url('https://fonts.googleapis.com/css?family=Merriweather:300,400,400i,700&display=swap');
html {
scrollbar-color: #404245 #2f3132;
@ -14,28 +12,5 @@ html {
}
body {
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%);
color: white;
}

View file

@ -1,6 +0,0 @@
@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

@ -1,59 +0,0 @@
<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

@ -1,98 +0,0 @@
<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

@ -1,229 +0,0 @@
<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

@ -0,0 +1,14 @@
<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

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

View file

@ -1,71 +1,5 @@
import { Card } from "./types";
const imgBaseURL = "https://mcg.zyg.ovh/images/cards/";
export function cardFullName(card: Card): string {
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;
}
export function cardImageURL(cardid: string): string {
return `${imgBaseURL}${cardid}.webp`;
}

View file

@ -1,42 +1,21 @@
import Dexie from "dexie";
import { Card, CardFilter, StoredImage } from "./types";
import { cardFullName } from "./card";
import { Card, CardFilter } from "./types";
class CardDatabase extends Dexie {
public cards: Dexie.Table<Card, string>;
public images: Dexie.Table<StoredImage, string>;
public constructor() {
super("CardDatabase");
this.version(1).stores({
cards: "ID,Set,Type,Cost,Power",
images: "id"
cards: "ID,Set,Type,Cost,Power"
});
this.cards = this.table("cards");
this.images = this.table("images");
}
}
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 let Database = new CardDatabase();
export async function getCards(filter: CardFilter) {
if (Database == null) {
throw new Error("Database was not initialized, init with 'initDB()'");
}
let table = Database.cards;
// Get best IDB index
let query: Dexie.Collection<Card, string>;
@ -64,112 +43,95 @@ export async function getCards(filter: CardFilter) {
}
}
const results = query.filter(x => {
if (filter.Name) {
if (
!cardFullName(x)
.toLowerCase()
.includes(filter.Name.toLowerCase())
) {
return false;
}
}
if (filter.Rules) {
if (
!`${x.Keywords.join(" ~ ")} ~ ${x.Text}`
.toLowerCase()
.includes(filter.Rules.toLowerCase())
) {
return false;
}
}
if (filter.Traits && filter.Traits.length > 0) {
let found = false;
for (const trait of x.Traits) {
if (filter.Traits.includes(trait)) {
found = true;
break;
return await query
.filter(x => {
if (filter.Name) {
if (
!`${x.Name}, ${x.Subname}`
.toLowerCase()
.includes(filter.Name.toLowerCase())
) {
return false;
}
}
if (!found) {
return false;
}
}
if (filter.Sets && filter.Sets.length > 0) {
if (!filter.Sets.includes(x.Set)) {
return false;
}
}
if (filter.Types && filter.Types.length > 0) {
if (!filter.Types.includes(x.Type)) {
return false;
}
}
if (filter.Elements && filter.Elements.length > 0) {
let found = false;
for (const element of x.Element) {
if (filter.Elements.includes(element)) {
found = true;
break;
if (filter.Rules) {
if (
!`${x.Keywords.join(" ~ ")} ~ ${x.Text}`
.toLowerCase()
.includes(filter.Rules.toLowerCase())
) {
return false;
}
}
if (x.Requirement) {
for (const element in x.Requirement) {
if (filter.Traits && filter.Traits.length > 0) {
let found = false;
for (const trait of x.Traits) {
if (filter.Traits.includes(trait)) {
found = true;
break;
}
}
if (!found) {
return false;
}
}
if (filter.Sets && filter.Sets.length > 0) {
if (!filter.Sets.includes(x.Set)) {
return false;
}
}
if (filter.Types && filter.Types.length > 0) {
if (!filter.Types.includes(x.Type)) {
return false;
}
}
if (filter.Elements && filter.Elements.length > 0) {
let found = false;
for (const element of x.Element) {
if (filter.Elements.includes(element)) {
found = true;
break;
}
}
}
if (x.ProblemRequirement) {
for (const element in x.ProblemRequirement) {
if (filter.Elements.includes(element)) {
found = true;
break;
if (x.Requirement) {
for (const element in x.Requirement) {
if (filter.Elements.includes(element)) {
found = true;
break;
}
}
}
if (x.ProblemRequirement) {
for (const element in x.ProblemRequirement) {
if (filter.Elements.includes(element)) {
found = true;
break;
}
}
}
if (!found) {
return false;
}
}
// 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 (filter.Powers && filter.Powers.length > 0) {
if (
typeof x.Power === "undefined" ||
!filter.Powers.includes(x.Power)
) {
return false;
}
}
if (!found) {
return false;
if (filter.Costs && filter.Costs.length > 0) {
if (typeof x.Cost === "undefined" || !filter.Costs.includes(x.Cost)) {
return false;
}
}
}
if (filter.Powers && filter.Powers.length > 0) {
if (typeof x.Power === "undefined" || !filter.Powers.includes(x.Power)) {
return false;
if (filter.Rarities && filter.Rarities.length > 0) {
if (!filter.Rarities.includes(x.Rarity)) {
return false;
}
}
}
if (filter.Costs && filter.Costs.length > 0) {
if (typeof x.Cost === "undefined" || !filter.Costs.includes(x.Cost)) {
return false;
}
}
if (filter.Rarities && filter.Rarities.length > 0) {
if (!filter.Rarities.includes(x.Rarity)) {
return false;
}
}
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)
return true;
})
.toArray();
}

View file

@ -1,211 +0,0 @@
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));
}

View file

@ -1,18 +0,0 @@
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];
}
}

View file

@ -1,48 +0,0 @@
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

@ -1,81 +0,0 @@
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

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

View file

@ -1,34 +0,0 @@
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 }
});
}
}

View file

@ -1,262 +0,0 @@
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);
}
}

View file

@ -1,90 +0,0 @@
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;
}

View file

@ -1,38 +0,0 @@
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";
}

View file

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

View file

@ -1,9 +1,8 @@
import { SetFile } from "./types";
import { Database } from "./database";
import axios from "axios";
const baseURL = "https://mcg.zyg.ovh/setdata/";
export const allSets = [
const allSets = [
"PR",
"CN",
"RR",
@ -19,27 +18,7 @@ export const allSets = [
"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() {
if (Database == null) {
throw new Error("Database was not initialized, init with 'initDB()'");
}
const itemcount = await Database.cards.count();
if (itemcount > 100) {
// DB already filled, exit early
@ -48,19 +27,18 @@ export async function loadSets() {
const sets = await Promise.all(allSets.map(set => downloadSet(set)));
await Promise.all(
sets.map(async set => {
if (Database == null) {
throw new Error("Database was not initialized, init with 'initDB()'");
}
console.log(`Processing cards from ${set.Name}`);
return await Database.cards.bulkPut(set.Cards);
})
);
}
async function downloadSet(setid: string): Promise<SetFile> {
const setdata = await axios(`${baseURL}${setid.toLowerCase()}.json`);
setdata.data.Cards = (setdata.data as SetFile).Cards.map(c => {
const setfile = await fetch(`${baseURL}${setid.toLowerCase()}.json`);
const setdata: SetFile = await setfile.json();
setdata.Cards = setdata.Cards.map(c => {
c.Set = setid;
return c;
});
return setdata.data;
return setdata;
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,61 +0,0 @@
"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 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);
@ -21,10 +21,7 @@ export default new Router({
{
path: "/build",
name: "deck-editor",
component: DeckBuilder,
meta: {
topnav: "Deck Builder"
}
component: DeckBuilder
},
{
path: "/game",
@ -39,26 +36,12 @@ export default new Router({
{
path: "/lobby",
name: "lobby",
component: Lobby,
meta: {
topnav: "Lobby"
}
component: Lobby
},
{
path: "/join/:id",
name: "lobby-join",
component: Lobby,
meta: {
topnav: "Lobby"
}
},
{
path: "/settings",
name: "settings",
component: SettingsView,
meta: {
topnav: "settings"
}
path: "/room",
name: "room",
component: RoomView
}
]
});

View file

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

View file

@ -1,12 +0,0 @@
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

@ -1,12 +0,0 @@
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;

View file

@ -1,27 +0,0 @@
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

@ -1,37 +0,0 @@
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;

View file

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

View file

@ -1,59 +0,0 @@
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

@ -1,60 +0,0 @@
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

@ -1,30 +0,0 @@
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

@ -1,44 +0,0 @@
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

@ -1,57 +0,0 @@
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;
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,16 +0,0 @@
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

@ -1,52 +0,0 @@
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

@ -1,36 +0,0 @@
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

@ -1,39 +0,0 @@
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

@ -1,131 +0,0 @@
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,5 +1,6 @@
import { MockHelper, EventHook } from "@/testing";
import { MockHelper } from "@/testing";
import { NetworkMessage, LocalClient, ChatMessage } from "@/network";
import { EventHook } from "@/testing/EventHook";
const sampleRoom = () => ({
max_players: 3,

View file

@ -1,479 +1,14 @@
<template>
<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>
<section class="deckbuilder"></section>
</template>
<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>
<style lang="scss" scoped></style>
<script lang="ts">
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({
components: {
TopNav,
DeckList,
CardPicker
}
components: {}
})
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);
}
}
}
export default class DeckBuilder extends Vue {}
</script>

View file

@ -1,118 +1,23 @@
<template>
<section class="draftview">
<section class="playerlist">
<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" />
<b>Players</b>
</section>
<section class="pool"><b>Card pool</b></section>
<section class="cardlist"><b>Cards</b></section>
</section>
</template>
<style lang="scss" scoped>
@import "@/assets/scss/_variables.scss";
$player-not-picked: $red;
$player-picked: $green;
$player-me: $purple;
$border-opacity: 0.6;
.draftview {
background: url("../assets/images/backgrounds/draftbg.webp") center;
display: grid;
height: 100vh;
gap: 10px;
grid-template-columns: minmax(200px, 1fr) 3fr minmax(250px, 1fr);
& > section {
padding: 10px 20px;
grid-gap: 10px;
grid-template-columns: 200px 1fr 250px;
section {
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) {
@ -149,95 +54,9 @@ $border-opacity: 0.6;
<script lang="ts">
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({
components: {
CardPicker,
DeckList
}
components: {}
})
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
};
}
}
export default class DraftView extends Vue {}
</script>

View file

@ -1,140 +1,12 @@
<template>
<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>
<section class="home"></section>
</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">
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({
components: {}
})
export default class Home extends Vue {
private projectName: string = "MLPCARDGAME";
private projectVersion: string = versionString;
}
export default class Home extends Vue {}
</script>

View file

@ -1,374 +1,14 @@
<template>
<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>
<section class="lobby"></section>
</template>
<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>
<style lang="scss" scoped></style>
<script lang="ts">
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({
components: {
TopNav
}
components: {}
})
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);
}
}
export default class Lobby extends Vue {}
</script>

14
src/views/Room.vue Normal file
View file

@ -0,0 +1,14 @@
<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>

View file

@ -1,191 +0,0 @@
<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>

View file

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

View file

@ -1,17 +0,0 @@
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

@ -1,72 +0,0 @@
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

@ -1,9 +0,0 @@
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