Compare commits

..

105 commits

Author SHA1 Message Date
f8b73e38c2 minor backend rework 2019-06-04 18:01:32 +02:00
e963576e46 crude games list 2019-06-04 17:00:09 +02:00
b56aedbca6 (lint) 2019-06-04 16:58:44 +02:00
7182121423 eslint setup 2019-06-04 12:13:19 +02:00
bfbd1cbf0a gameKeys iteration 2019-06-03 22:28:32 +02:00
c1f25d008b more eye candy 2019-06-03 16:57:10 +02:00
dafc8c66fd vastly improved SVG crystals 2019-06-03 14:35:02 +02:00
7b5bfa4fe1 game and session keys 2019-05-28 17:42:21 +02:00
e55e659cd0 fix production builds 2019-05-28 11:06:57 +02:00
63999a9e74 play button :) 2019-05-27 17:14:57 +02:00
8d3bddfd5b usercp/deckcp split 2019-05-27 17:06:10 +02:00
063893e3e8 button icons 2019-05-27 17:04:51 +02:00
1bc80b6d89 Common "HeaderIntern" for user routes 2019-05-27 15:50:23 +02:00
3b2ff65054 sessionID as prop 2019-05-27 14:34:07 +02:00
348b4b2702 layout fixes DeckList and subcomponents 2019-05-27 14:23:33 +02:00
9b8393c779 minor errata 2019-05-27 13:51:24 +02:00
f5425829dd Crystal component 2019-05-27 13:43:18 +02:00
abf53aea2d strip out debug output 2019-05-27 12:11:38 +02:00
d912b83608 Layout with toolbar 2019-05-27 12:10:50 +02:00
d9bd46b287 Prettier deck editor 2019-05-27 11:11:45 +02:00
9616a2fe97 Delete deck 2019-05-24 13:41:48 +02:00
0475e45275 Add new deck 2019-05-24 13:30:26 +02:00
38b92829cd zoom cursor 2019-05-24 11:26:02 +02:00
0eeaa08239 DeckEditor v-model untangling 2019-05-24 11:25:35 +02:00
077806411c reactivity DeckEditor 2019-05-23 18:11:39 +02:00
f363ead51a oop DeckEditor (codebase join for /decks/add) 2019-05-23 15:00:01 +02:00
db4677d751 deck editing 2019-05-23 13:58:59 +02:00
8aec2a0b6c deck validation 2019-05-20 18:09:33 +02:00
3bbb949400 deck list request in decklist component (duh) 2019-05-20 17:06:40 +02:00
da69fb8e9d Editor close 2019-05-20 17:06:12 +02:00
1354b59cb0 decks/modify route 2019-05-20 17:02:39 +02:00
417c8cdd75 decklist editor form layout 2019-05-17 15:24:52 +02:00
4f5522ff77 better decklist parser + validator 2019-05-17 01:16:22 +02:00
94a8f69cfd DeckEditor component 2019-05-16 17:47:58 +02:00
323d6d2f35 better deck parts splitter 2019-05-16 15:54:33 +02:00
296ab79bd6 crude deck edit form 2019-05-16 08:10:03 +02:00
e028a7c714 crystal svg 2019-05-16 08:09:43 +02:00
4c32aaf1b5 Card Image lazy load 2019-05-15 02:05:53 +02:00
310f08dc39 Card Images 2019-05-15 01:39:16 +02:00
126f800111 Card Component 2019-05-15 01:13:44 +02:00
e372ed1b7c nicer deck template 2019-05-15 00:27:42 +02:00
fc0228bd1f (lint) + legacy cleanup 2019-05-14 19:54:32 +02:00
22dd25b070 Proof-of-concept Decklisting 2019-05-14 17:59:36 +02:00
2b304ca407 Views: Updated for (async) computed properties 2019-05-14 16:20:38 +02:00
48b1a9bbc3 async computed support 2019-05-14 16:18:41 +02:00
e52f82477a decklist parser outline 2019-05-14 13:46:11 +02:00
0a5df6accc cardsdb computed property 2019-05-14 13:45:41 +02:00
3f6b91f5ca Crude UserCP deck display 2019-05-13 17:35:32 +02:00
c2f077c198 better database API 2019-05-13 17:35:15 +02:00
b60ac2105a defined test database 2019-05-10 14:34:58 +02:00
9d1e790a0d /decks/list route (schema missing) 2019-05-10 14:09:49 +02:00
6dcb5a4963 less stuff into Redis, /user/info from sqlite 2019-05-09 18:03:35 +02:00
cbc27f1706 legacy purge 2019-05-09 15:53:11 +02:00
d4358479f6 sqldb cleanup 2019-05-09 14:33:43 +02:00
0f7176999b logout JSON schema 2019-05-09 14:32:42 +02:00
0d604ef320 route logging scheme 2019-05-09 14:32:28 +02:00
e64f5dbabf /user/info route 2019-05-09 14:31:54 +02:00
e6bfa62381 About -> UserCP 2019-05-09 14:20:03 +02:00
25630bba41 (lint) 2019-05-09 03:33:03 +02:00
366339fc9a login form rules 2019-05-09 03:31:56 +02:00
0a61d2750b snackbar with colors, close button text 2019-05-09 03:31:43 +02:00
066073fa54 rename validated event 2019-05-08 22:55:15 +02:00
eee3ed96ac db: input validation and error messages 2019-05-08 21:35:11 +02:00
4b6b5f339f generic reusable header 2019-05-08 21:34:50 +02:00
86a20f2982 FormDialog error message (snackbar) 2019-05-08 21:34:40 +02:00
db53964007 cookie expiry data from backend 2019-05-08 18:45:24 +02:00
7c41b94a38 no sqlite in logout 2019-05-07 22:42:29 +02:00
1505667e1e logout route schema 2019-05-07 22:33:37 +02:00
f51796902f missing comment 2019-05-07 22:29:01 +02:00
ed18dce3ea login/logout routing 2019-05-07 22:28:51 +02:00
84ec601e2a logout 2019-05-07 22:15:18 +02:00
655f64c193 custom event "confirm" 2019-05-07 22:14:23 +02:00
3a0e889626 usercp for ants 2019-05-07 18:09:45 +02:00
aac6b5e7b4 axios plugin 2019-05-07 18:03:40 +02:00
640cfe3b03 probing user names is still possible with "register", so doesn't matter 2019-05-07 17:37:11 +02:00
98a774a54d better cookie system 2019-05-07 17:37:07 +02:00
01049e67fd (lint) 2019-05-07 10:55:01 +02:00
e8dbb7b161 Better Forms 2019-05-07 05:06:45 +02:00
f39f80e64e PW confirmation 2019-05-06 17:21:21 +02:00
939cc8d504 frontend versions 2019-05-06 17:02:23 +02:00
0afb906c2d FormDialog abstract component 2019-05-06 17:02:12 +02:00
0d96ee66e7 Simple Register/Login Forms 2019-05-06 16:18:23 +02:00
cf84d4ffec backend versions 2019-05-06 16:17:13 +02:00
9fb82dcfaa Begin Login Form 2019-05-06 04:05:32 +02:00
a6b9646b87 Stripped App component to ExampleApp ~ fix reloading, resizing 2019-05-02 11:20:14 +02:00
4ee00885fe move game source to frontend ~ refine Makefile ~ 2019-04-29 17:41:53 +02:00
4dd6236856 vue ui layout 2019-02-28 18:04:16 +01:00
3bd4b71c55 typo; bogus modal in about 2019-02-23 02:59:35 +01:00
ac38c6f0c4 vuetify 2019-02-22 21:10:17 +01:00
6e70b9a76a basic vue frontend 2019-02-22 20:23:42 +01:00
59b5f68b8b routing cleanup 2019-02-19 21:50:29 +01:00
c3fd13e358 classify db 2019-02-19 21:36:14 +01:00
60edcf86b7 singletons 2019-02-19 19:58:09 +01:00
7221dd31af classify session 2019-02-16 19:15:49 +01:00
d0180f3b38 routes maintainability 2019-02-16 19:04:16 +01:00
e78570f298 routes for basic user mgmt 2019-02-16 19:03:21 +01:00
d9eb9796b1 JSON schema working 2019-02-15 13:06:16 +01:00
6306a4457d session start and resume 2019-02-15 03:50:11 +01:00
24484a8ddb typo 2019-02-14 19:23:49 +01:00
d30f92aa46 basic session management, not really working 2019-02-14 17:47:47 +01:00
678a30e94d basic route module without schemata, basic session agnostic socket 2019-02-14 09:54:41 +01:00
2ea24ad1da all cookies to fastify; cleanup 2019-02-14 02:17:24 +01:00
acd078458f goof around with fastify. broken but funny. 2019-02-13 17:25:35 +01:00
f278d03519 beautification/typo 2019-02-13 17:25:05 +01:00
ffb7dd8da6 decoupled backend 2019-02-07 17:03:20 +01:00
102 changed files with 16675 additions and 839 deletions

View file

@ -1,19 +0,0 @@
# node stuff
node_modules
npm-debug.log
# big files
**/*.xcf
**/*.bundle.js
# docker stuff
Dockerfile
.dockerignore
docker-compose.yml
# container volumes
src
views
public_html
inc
server.coffee

4
.gitignore vendored
View file

@ -1 +1,3 @@
**/*.bundle.js **/node_modules
**/fftcg.db
**/org.vue.*.json

View file

@ -1,16 +0,0 @@
FROM node:latest
# some dir for our code
WORKDIR /app
# container port
EXPOSE 3000
# install dependencies
COPY package*.json .
RUN yarn
# copy code
COPY . .
# this is how we start
CMD ["yarn", "start"]

40
Makefile Normal file
View file

@ -0,0 +1,40 @@
COMPOSE:=docker-compose
ifeq ($(shell which $(COMPOSE) > /dev/null; echo $$?),0)
# prefer system's docker-compose
else ifeq ($(shell which pipenv > /dev/null; echo $$?),0)
# fallback to pipenv
COMPOSE:=$(shell pipenv run which $(COMPOSE))
$(info found compose as $(COMPOSE))
else
$(error compose not found, please install)
endif
# need root privileges?
PRIVGROUP:=docker
ifneq ($(findstring $(PRIVGROUP),$(shell groups)),$(PRIVGROUP))
$(info need to run compose as root)
COMPOSE:=sudo $(COMPOSE)
endif
CANARY:=node_modules/.yarn-integrity
.PHONY: all
all: develop
%/$(CANARY):
$(eval image:=$(patsubst %/$(CANARY),%,$@))
$(COMPOSE) build --pull $(image)
$(COMPOSE) run --rm $(image) yarn install --production=false
DFILES:=$(wildcard */Dockerfile)
IMAGES:=$(patsubst %/Dockerfile,%,$(DFILES))
.PHONY: develop
develop: $(patsubst %,%/$(CANARY),$(IMAGES))
$(COMPOSE) up
.PHONY: production
production:
$(COMPOSE) -f docker-compose.yml -f docker-compose.prod.yml build
$(COMPOSE) -f docker-compose.yml -f docker-compose.prod.yml up -d

7
backend/.dockerignore Normal file
View file

@ -0,0 +1,7 @@
# node stuff
node_modules
npm-debug.log
# Docker stuff
Dockerfile
.dockerignore

8
backend/.eslintrc.js Normal file
View file

@ -0,0 +1,8 @@
module.exports = {
"extends": ["plugin:@fellow/coffee/recommended"],
"plugins": ["@fellow/coffee"],
"rules": {
"@fellow/coffee/indentation": ["error", { "value": 2 }],
"@fellow/coffee/colon-assignment-spacing": "off"
}
};

22
backend/Dockerfile Normal file
View file

@ -0,0 +1,22 @@
FROM node:lts AS dev
ENV NODE_ENV development
# some dir for our code
WORKDIR /app
# mount code
VOLUME ["/app"]
# this is how we start
CMD [ "yarn", "dev" ]
FROM node:lts AS prod
ENV NODE_ENV production
# some dir for our code
WORKDIR /app
# install dependencies
COPY package*.json yarn*.lock ./
RUN yarn --production
# copy code
COPY . .
USER node
# this is how we start
CMD [ "yarn", "start" ]

271
backend/db.coffee Normal file
View file

@ -0,0 +1,271 @@
# node libraries
bcrypt = (require 'bcrypt')
logger = (require 'logging').default 'db'
path = (require 'path')
sqlite3 = (require 'sqlite3').verbose()
# bruteforce countermeasure
saltRounds = 13
messages =
empty: 'Empty user name or password'
hash: 'Failed to process your data, try again later'
exists: 'User name is already taken'
noexists: 'Wrong user name or password'
password: 'Wrong user name or password'
db: 'Failed to access the database, try again later'
class FFTCGDB
constructor: (filename, truncate) ->
@db = new sqlite3.Database filename, (err) =>
if err
logger.error err.message
else
logger.info "OK opened '#{filename}'"
@db.run 'PRAGMA foreign_keys = ON;', (err) =>
logger.error err.message if err
if truncate == true
@db.run 'DROP TABLE IF EXISTS users;', (err) =>
logger.error err.message if err
@db.run '''
CREATE TABLE users (
user integer PRIMARY KEY,
login text NOT NULL COLLATE NOCASE,
pwdhash text NOT NULL,
settings text,
UNIQUE(login)
);
''', (err) =>
logger.error err.message if err
@db.run 'DROP TABLE IF EXISTS decks;', (err) =>
logger.error err.message if err
@db.run '''
CREATE TABLE decks (
deck integer PRIMARY KEY,
user integer NOT NULL,
json text,
FOREIGN KEY (user) REFERENCES users (user)
ON DELETE CASCADE
);
''', (err) =>
logger.error err.message if err
@db.run '''
INSERT INTO users VALUES (1,'jmm','$2b$13$jgDdHHDWqq1RV6PXxf7aOO6AbxqY6tbxIADyIO0FeXt2BlKQCCMzS',NULL);
'''
@db.run '''
INSERT INTO decks VALUES (1,1,'{
"name":"Antipode Bomb Version 6.0",
"note":"As Seen In Tournament: The North American Water Cup",
"cards":[
{"count":1,"serial":"1-192"},{"count":2,"serial":"7-132"},{"count":2,"serial":"8-037"},
{"count":2,"serial":"8-139"},{"count":1,"serial":"5-036"},{"count":3,"serial":"4-048"},
{"count":1,"serial":"2-026"},{"count":3,"serial":"8-043"},{"count":3,"serial":"4-021"},
{"count":3,"serial":"3-033"},{"count":1,"serial":"8-014"},{"count":2,"serial":"8-006"},
{"count":1,"serial":"8-042"},{"count":1,"serial":"6-027"},{"count":3,"serial":"5-019"},
{"count":2,"serial":"2-019"},{"count":2,"serial":"5-032"},{"count":3,"serial":"4-026"},
{"count":3,"serial":"1-057"},{"count":1,"serial":"1-048"},{"count":2,"serial":"8-036"},
{"count":3,"serial":"8-005"},{"count":3,"serial":"2-005"},{"count":1,"serial":"7-017"},
{"count":1,"serial":"8-007"}
]
}');
'''
logger.info 'OK clear'
close: ->
logger.debug 'shutting down'
new Promise (resolve, reject) =>
@db.close (err) ->
if err
logger.error "FAIL '#{err.message}'"
reject null
else
logger.info "OK closed"
resolve null
validate: (login, password) ->
defined = (value) -> value? and value isnt ''
new Promise (resolve, reject) ->
if (defined login) and (defined password)
# both are defined
resolve null
else
# no user name or password given
logger.info "validate: FAIL empty '#{login}' or password"
reject null
register: (login, password) ->
new Promise (resolve, reject) =>
# validate user input
@validate login, password
.then =>
# hash password
bcrypt.hash password, saltRounds, (err, hash) =>
if err
logger.warn "reg: FAIL hash for '#{login}'"
reject messages.hash
else
# try creating row in users table
stmt = @db.prepare '''
INSERT INTO users (login, pwdhash)
VALUES (?, ?)
'''
stmt.run [login, hash], (err) ->
stmt.finalize()
if err
logger.warn "reg: FAIL db '#{err.code}' for '#{login}'"
# user already exists
reject messages.exists
else
logger.info "reg: OK '#{login}'"
# registration successful
resolve null
.catch ->
reject messages.empty
login: (login, password) ->
new Promise (resolve, reject) =>
# validate user input
@validate login, password
.then =>
# get users table row
stmt = @db.prepare '''
SELECT *
FROM users
WHERE login = ?
'''
stmt.get [login], (err, row) ->
stmt.finalize()
if err
logger.warn "login: FAIL db '#{err.code}' for '#{login}'"
reject messages.db
else if not row
# hash the password for timing attack reasons
bcrypt.hash password, saltRounds, (err, hash) ->
logger.debug "login: FAIL nonexistent '#{login}'"
reject messages.noexists # user doesnt exist
else
bcrypt.compare password, row.pwdhash, (err, res) ->
if err
logger.warn "login: FAIL hash for '#{login}'"
reject messages.hash
if res == true
logger.debug "login: OK '#{row.login}'"
# login successful
resolve row.user
else
logger.debug "login: FAIL password for '#{login}'"
reject messages.password # login failed
.catch ->
reject messages.empty
getUser: (userID) ->
new Promise (resolve, reject) =>
# get users table row
stmt = @db.prepare '''
SELECT *
FROM users
WHERE user = ?
'''
stmt.get [userID], (err, row) ->
stmt.finalize()
if err
logger.warn "get: FAIL db '#{err.code}' for '#{userID}'"
reject messages.db
else if not row
logger.debug "get: FAIL nonexistent '#{userID}'"
reject messages.noexists # user doesnt exist
else
resolve
user: row.user
login: row.login
settings: row.settings
addDeck: (userID, deckCards) ->
new Promise (resolve, reject) =>
# try creating row in decks table
stmt = @db.prepare '''
INSERT INTO decks (user, json)
VALUES (?, ?)
'''
stmt.run [userID, (JSON.stringify deckCards)], (err) ->
stmt.finalize()
if err
logger.warn "addDeck: FAIL db '#{err.code}' for '#{userID}'"
reject messages.db
else
# eslint-disable-next-line @fellow/coffee/missing-fat-arrows
logger.debug "addDeck: OK '#{@lastID}'"
resolve @lastID
modDeck: (userID, deckID, deckCards) ->
new Promise (resolve, reject) =>
stmt = @db.prepare '''
UPDATE decks
SET json = ?
WHERE deck = ? AND user = ?
'''
stmt.run [(JSON.stringify deckCards), deckID, userID], (err) ->
stmt.finalize()
isUnchanged =
if err
logger.warn "modDeck: FAIL db '#{err.code}' for '#{deckID}'"
reject messages.db
# eslint-disable-next-line
else if @changes == 0
logger.warn "no changes for input (#{userID}, #{deckID}, #{JSON.stringify deckCards})!"
reject messages.db
else
resolve deckID
getDecks: (userID) ->
new Promise (resolve, reject) =>
stmt = @db.prepare '''
SELECT decks.deck, decks.json
FROM decks
INNER JOIN users ON decks.user = users.user
WHERE users.user = ?
'''
stmt.all [userID], (err, rows) ->
stmt.finalize()
if err
logger.warn "getDecks: FAIL db '#{err.code}' for '#{userID}'"
reject messages.db
else
logger.debug "getDecks: OK '#{userID}'"
resolve (id: row.deck, content: JSON.parse row.json for row, i in rows)
delDeck: (userID, deckID) ->
new Promise (resolve, reject) =>
stmt = @db.prepare '''
DELETE FROM decks
WHERE deck = ? AND user = ?
'''
stmt.run [deckID, userID], (err) ->
stmt.finalize()
if err
logger.warn "delDeck: FAIL db '#{err.code}' for '#{deckID}'"
reject messages.db
else
logger.debug "delDeck: OK '#{deckID}'"
resolve deckID
module.exports = new FFTCGDB path.resolve(__dirname, 'fftcg.db'), true

30
backend/package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "node-fftcg",
"version": "0.0.3",
"description": "FFTCG online using Socket.IO and CraftyJS on Node.js on Docker",
"author": "JMM <jmm@yavook.de>",
"main": "server.coffee",
"private": true,
"license": "UNLICENSED",
"scripts": {
"start": "coffee server.coffee",
"dev": "nodemon server.coffee --exec 'yarn lint && yarn start'",
"lint": "eslint $(find . -name node_modules -prune -o \\( -type f -iname '*.coffee' -print \\))",
"test": "echo \"Error: no test specified\" && exit 1"
},
"devDependencies": {
"@fellow/eslint-plugin-coffee": "^0.4.13",
"eslint": "^5.16.0",
"nodemon": "^1.19.0"
},
"dependencies": {
"bcrypt": "^3.0.6",
"coffeescript": "^2.4.1",
"fastify": "^2.3.0",
"fastify-cors": "^2.1.3",
"fastify-ws": "^1.0.1",
"logging": "^3.2.0",
"redis": "^2.8.0",
"sqlite3": "^4.0.4"
}
}

View file

@ -0,0 +1,34 @@
logger = (require 'logging').default '/decks/add'
# session storage (volatile data)
session = (require '../../session')
# fftcg.db (persistent data)
fftcgdb = (require '../../db')
module.exports =
url: '/decks/add'
method: 'POST'
# schema: (require './modify.schema')
handler: (request, reply) ->
session.check request.body.session ? ""
.then (userid) ->
# active session found, get associated user
fftcgdb.addDeck (userid), (request.body.deckCards)
.then (deckID) ->
logger.info "OK user '#{userid}' added deck '#{deckID}'"
reply.send
success: true
deck: deckID
.catch (err) ->
# couldnt get user details
logger.warn "FAIL '#{err}' for user id '#{userid}'"
reply.send
success: false
.catch ->
# no session found
logger.info "FAIL '#{request.body.session}' session not found"
reply.send
success: false

View file

@ -0,0 +1,34 @@
logger = (require 'logging').default '/decks/delete'
# session storage (volatile data)
session = (require '../../session')
# fftcg.db (persistent data)
fftcgdb = (require '../../db')
module.exports =
url: '/decks/delete'
method: 'POST'
# schema: (require './modify.schema')
handler: (request, reply) ->
session.check request.body.session ? ""
.then (userid) ->
# active session found, get associated user
fftcgdb.delDeck (userid), (request.body.deckID)
.then (deckID) ->
logger.info "OK user '#{userid}' deleted deck '#{deckID}'"
reply.send
success: true
deck: deckID
.catch (err) ->
# couldnt get user details
logger.warn "FAIL '#{err}' for user id '#{userid}'"
reply.send
success: false
.catch ->
# no session found
logger.info "FAIL '#{request.body.session}' session not found"
reply.send
success: false

View file

@ -0,0 +1,34 @@
logger = (require 'logging').default '/decks/list'
# session storage (volatile data)
session = (require '../../session')
# fftcg.db (persistent data)
fftcgdb = (require '../../db')
module.exports =
url: '/decks/list'
method: 'POST'
# schema: (require './info.schema')
handler: (request, reply) ->
session.check request.body.session ? ""
.then (userid) ->
# active session found, get associated user
fftcgdb.getDecks (userid)
.then (decks) ->
logger.debug "OK '#{userid}' got decks"
reply.send
success: true
decks: decks
.catch (err) ->
# couldnt get user details
logger.warn "FAIL '#{err}' for user id '#{userid}'"
reply.send
success: false
.catch ->
# no session found
logger.info "FAIL '#{request.body.session}' session not found"
reply.send
success: false

View file

@ -0,0 +1,34 @@
logger = (require 'logging').default '/decks/modify'
# session storage (volatile data)
session = (require '../../session')
# fftcg.db (persistent data)
fftcgdb = (require '../../db')
module.exports =
url: '/decks/modify'
method: 'POST'
# schema: (require './modify.schema')
handler: (request, reply) ->
session.check request.body.session ? ""
.then (userid) ->
# active session found, get associated user
fftcgdb.modDeck (userid), (request.body.deckID), (request.body.deckCards)
.then (deckID) ->
logger.info "OK user '#{userid}' modified deck '#{deckID}'"
reply.send
success: true
deck: deckID
.catch (err) ->
# couldnt get user details
logger.warn "FAIL '#{err}' for user id '#{userid}'"
reply.send
success: false
.catch ->
# no session found
logger.info "FAIL '#{request.body.session}' session not found"
reply.send
success: false

View file

@ -0,0 +1,26 @@
logger = (require 'logging').default '/games/list'
# session storage (volatile data)
session = (require '../../session')
module.exports =
url: '/games/list'
method: 'POST'
# schema: (require './info.schema')
handler: (request, reply) ->
session.check request.body.session ? ""
.then (userid) ->
# active session found, get associated user
session.getGames()
.then (games) ->
logger.debug "OK '#{userid}' got games"
reply.send
success: true
games: games
.catch ->
# no session found
logger.info "FAIL '#{request.body.session}' session not found"
reply.send
success: false

View file

@ -0,0 +1,12 @@
module.exports =
url: '/test'
method: 'POST'
handler: (request, reply) ->
logger.info 'Cookies', request.cookies
logger.info 'Body', request.body
logger.info 'Query', request.query
logger.info 'Params', request.params
reply.setCookie 'foo', 'foo'
reply.send
hello: 'world'

View file

@ -0,0 +1,34 @@
logger = (require 'logging').default '/user/info'
# session storage (volatile data)
session = (require '../../session')
# fftcg.db (persistent data)
fftcgdb = (require '../../db')
module.exports =
url: '/user/info'
method: 'POST'
schema: (require './info.schema')
handler: (request, reply) ->
session.check request.body.session ? ""
.then (userid) ->
# active session found, get associated user
fftcgdb.getUser (userid)
.then (user) ->
logger.debug "OK '#{user.login}' got info"
reply.send
success: true
user: user
.catch (err) ->
# couldnt get user details
logger.warn "FAIL '#{err}' for user id '#{userid}'"
reply.send
success: false
.catch ->
# no session found
logger.info "FAIL '#{request.body.session}' session not found"
reply.send
success: false

View file

@ -0,0 +1,24 @@
module.exports =
body:
session: type: 'string'
response:
200:
type: 'object'
required: ['success']
properties:
success: type: 'boolean'
user:
type: 'object'
required: ['user', 'login', 'settings']
properties:
user: type: 'integer'
login: type: 'string'
settings: type: 'string'
# user is required iff success
if:
properties:
success:
const: true
then:
required: ['user']
else: true

View file

@ -0,0 +1,41 @@
logger = (require 'logging').default '/user/login'
# session storage (volatile data)
session = (require '../../session')
# fftcg.db (persistent data)
fftcgdb = (require '../../db')
module.exports =
url: '/user/login'
method: 'POST'
schema: (require './user.schema')
handler: (request, reply) ->
new Promise (resolve) ->
session.check request.body.session ? ""
.then (user) ->
# active session found
logger.debug "OK '#{user.login}' resumed session '#{request.body.session}'"
resolve request.body.session
.catch ->
fftcgdb.login request.body.login, request.body.password
.then (user) ->
# login successful: start new session
logger.info "OK '#{request.body.login}'"
session.start user
.then (cookie_data) ->
resolve cookie_data
.catch (err) ->
# login failed
logger.info "FAIL '#{request.body.login}: #{err}'"
reply.send
success: false
message: err
.then (cookie_data) ->
# login or resume successful
reply.send
success: true
message: JSON.stringify cookie_data

View file

@ -0,0 +1,24 @@
logger = (require 'logging').default '/user/logout'
# session storage (volatile data)
session = (require '../../session')
module.exports =
url: '/user/logout'
method: 'POST'
schema: (require './logout.schema')
handler: (request, reply) ->
new Promise (resolve) ->
session.destroy request.body.session ? ""
.then ->
# active session found
logger.debug "OK removed session '#{request.body.session}'"
resolve null
.catch ->
resolve null
.then ->
reply.send
success: true

View file

@ -0,0 +1,11 @@
module.exports =
body:
session: type: 'string'
response:
200:
type: 'object'
required: ['success']
properties:
success:
type: 'boolean'
const: true

View file

@ -0,0 +1,22 @@
logger = (require 'logging').default '/user/register'
# fftcg.db (persistent data)
fftcgdb = (require '../../db')
module.exports =
url: '/user/register'
method: 'POST'
schema: (require './user.schema')
handler: (request, reply) ->
fftcgdb.register(request.body.login, request.body.password)
.then ->
logger.info "OK '#{request.body.login}'"
reply.send
success: true
.catch (err) ->
logger.debug "FAIL '#{request.body.login}'"
reply.send
success: false
message: err

View file

@ -0,0 +1,21 @@
module.exports =
body:
session: type: 'string'
login: type: 'string'
password: type: 'string'
response:
200:
type: 'object'
required: ['success']
properties:
success: type: 'boolean'
message: type: 'string'
# message is required iff not success
if:
properties:
success:
const: false
then:
required: ['message']
else: true

65
backend/server.coffee Normal file
View file

@ -0,0 +1,65 @@
# node libraries
# (require 'debug').enable 'FFTCG'
logger = (require 'logging').default 'FFTCG'
fastify = (require 'fastify')
logger: level: 'warn'
# fastify and plugin framework
fastify.register (require 'fastify-ws'), library: 'uws'
fastify.register (require 'fastify-cors')
# API routes
fastify.route (require "./routes/#{route}") for route in [
# test route
'test'
# log in user
'user/login'
# user info
'user/info'
# log out user
'user/logout'
# register user
'user/register'
# list decks
'decks/list'
# add deck
'decks/add'
# modify deck
'decks/modify'
# delete deck
'decks/delete'
# list games
'games/list'
]
# request logging
fastify.addHook 'onRequest', (req, res, next) ->
logger.debug 'requested', req.url
next()
# finalize loadup
fastify.ready()
.then ->
# create websocket on successful load
socket = (require './socket')
fastify.ws.on 'connection', socket
.catch (err) ->
# abort on load failure
logger.error err
process.exit 1
# start server
fastify.listen 3001, '0.0.0.0'
.catch (err) ->
logger.error err
# Handle termination
process.on 'SIGUSR2', ->
(require './db').close()
.then ->
logger.info 'shutting down normally after SIGINT'
.catch ->
logger.info 'error shutting down after SIGINT'
.finally ->
process.exit()

171
backend/session.coffee Normal file
View file

@ -0,0 +1,171 @@
# node libraries
redis = (require 'redis')
crypto = (require 'crypto')
logger = (require 'logging').default 'session'
# expiry times in days
EXPIRY =
# games expire 1 week after creation
game: 7
# logins expire 1 month after last action
login: 30
class FFTCGSESSION
constructor: ->
@redis = redis.createClient
host: 'redis'
port: 6379
@redis.on 'error', (err) ->
logger.error err.message
sessionKey: (digest) -> "session.#{digest}"
gameKey: (digest) -> "game.#{digest}"
start: (userid) ->
new Promise (resolve) =>
# hash userid
hmac = crypto.createHmac 'sha256', Math.random().toString()
hmac.update userid.toString()
digest = hmac.digest 'base64'
# push (hash, userid) into DB for the configured timespan
@redis.setex (@sessionKey digest), EXPIRY.login * 86400, userid, =>
logger.info "OK '#{@sessionKey digest}' created"
# return cookie data
resolve
value: digest
properties:
expires: EXPIRY.login
destroy: (digest) ->
new Promise (resolve, reject) =>
# delete hash immediately
@redis.del (@sessionKey digest), (err, res) =>
if res == 0
reject null
else
logger.info "OK '#{@sessionKey digest}' deleted"
resolve null
check: (digest) ->
new Promise (resolve, reject) =>
# refresh expiry timer on digest
@redis.expire (@sessionKey digest), EXPIRY.login * 86400, (err, res) =>
if res == 0
reject null
else
@redis.get (@sessionKey digest), (err, res) =>
logger.debug "OK '#{@sessionKey digest}' resumed"
resolve res
newGame: (userid) ->
new Promise (resolve, reject) =>
# generate hash
hmac = crypto.createHmac 'sha256', Math.random().toString()
hmac.update userid.toString()
digest = hmac.digest 'base64'
# insert game key
@redis.hsetnx (@gameKey digest), 'owner', userid, (err, res) =>
if res == 0
@redis.del (@gameKey digest)
reject null
else
@redis.expire (@gameKey digest), EXPIRY.game * 86400, (err, res) =>
if res == 0
@redis.del (@gameKey digest)
reject null
else
# add game to active set
@redis.sadd (@gameKey 'active'), (@gameKey digest), (err, res) =>
# return game ID
logger.info "OK '#{@gameKey digest}' created"
resolve digest
getGames: ->
new Promise (resolve) =>
# function to return all active gameKeys
activeGameKeys = (set, cursor) =>
# start iteration
set ?= new Set()
cursor ?= '0'
return new Promise (resolve, reject) =>
# scan "active" gameKey
@redis.sscan (@gameKey 'active'), cursor, 'COUNT', '100', (err, res) ->
if err
reject null
# add to results set
cursor = res[0]
for key in res[1]
set.add key
if cursor == '0'
# done on cursor = 0
resolve set
else
# recursive call (resolve one step deeper)
allGames set, cursor
.then (set) ->
resolve set
activeGameKeys()
.then (set) =>
activeGames = []
for key in Array.from set
activeGames.push new Promise (resolve) =>
@redis.hget key, 'owner', (err, res) ->
if(err)
resolve null
resolve res
Promise.all activeGames
.then (activeGames) ->
resolve activeGames
joinGame: (digest, userid) ->
new Promise (resolve, reject) =>
# refresh expiry timer on digest
@redis.expire (@gameKey digest), EXPIRY.game * 86400, (err, res) =>
if res == 0
reject null
else
# insert opponent value
@redis.hsetnx (@gameKey digest), 'opponent', userid, (err, res) =>
if res == 0
reject null
else
# return game ID
logger.info "OK '#{@gameKey digest}' joined"
resolve digest
updateGame: (digest, state) ->
new Promise (resolve, reject) =>
# refresh expiry timer on digest
@redis.expire (@gameKey digest), EXPIRY.game * 86400, (err, res) =>
if res == 0
reject null
else
# update state value
@redis.hset (@gameKey digest), 'state', (JSON.stringify state), (err, res) =>
if res == 0
reject null
else
# return game ID
logger.info "OK '#{@gameKey digest}' updated"
resolve digest
module.exports = new FFTCGSESSION

42
backend/socket.coffee Normal file
View file

@ -0,0 +1,42 @@
# node libraries
path = (require 'path')
logger = (require 'logging').default 'socket'
# my libraries
module.exports = (socket) ->
logger.info 'OK connect'
socket.on 'message', (msg) ->
# echo server
logger.info "OK received '#{msg}'"
socket.send "Re: #{msg}"
socket.on 'close', ->
logger.info 'OK disconnect'
# FFTCGSOCKET = (http, session) ->
# that = @
#
# # create server socket
# @io = socketio http
# @io.use session
#
# # on new connection
# @io.on 'connection', (socket) ->
# @session = socket.handshake.session
# logger.debug "session '#{@session.id}' connected"
# logger.debug "is user '#{@session.userID}'" if @session.userID
#
# socket.on 'disconnect', ->
# logger.debug "session '#{that.session.id}' disconnected"
# logger.debug "is user '#{that.session.userID}'" if that.session.userID
#
# return
#
#
# FFTCGSOCKET::close = ->
# logger.info 'shutting down'
#
#
# module.exports = FFTCGSOCKET

View file

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="de" dir="ltr">
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>
Hello World
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script type="text/javascript">
const host = location.origin.replace(/^http/, 'ws')
const ws = new WebSocket(host)
ws.onmessage = msg => console.log(msg.data)
ws.onopen = () => {
console.log('Hai')
ws.send('Ping') // Send the message 'Ping' to the server
}
axios.post('/user/register',{
login: 'jmm',
password: '123'
})
.then( (response) => {
console.log('register', response)
})
axios.post('/user/login',{
login: 'jmm',
password: '123'
})
.then( (response) => {
console.log('login', response)
console.log('cookie', document.cookie)
})
</script>
</body>
</html>

3169
backend/yarn.lock Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,24 @@
version: "2.3"
services:
redis:
restart: "no"
backend:
build:
target: dev
restart: "no"
volumes:
- "./backend:/app"
frontend:
build:
target: dev
restart: "no"
volumes:
- "./frontend:/app"
- "./frontend/.vue-cli-ui:/root/.vue-cli-ui"
ports:
- "3000:3000"
- "8080:8080"

16
docker-compose.prod.yml Normal file
View file

@ -0,0 +1,16 @@
version: "2.3"
services:
redis:
restart: unless-stopped
backend:
build:
target: prod
restart: unless-stopped
frontend:
build:
target: prod
restart: unless-stopped

View file

@ -1,20 +1,16 @@
version: "2" version: "2.3"
services: services:
fftcg:
build: .
command: "yarn debug"
restart: "no"
volumes:
- "${PWD}/src:/app/src"
- "${PWD}/views:/app/views:ro"
- "${PWD}/public_html:/app/public_html"
- "${PWD}/inc:/app/inc:ro"
- "${PWD}/server.coffee:/app/server.coffee:ro"
# - "${PWD}/fftcg.db:/app/fftcg.db"
ports:
- "3000:3000"
redis: redis:
image: redis:alpine image: redis:alpine
restart: "no"
backend:
build:
context: ./backend
ports:
- "3001:3001"
frontend:
build:
context: ./frontend

BIN
fftcg.db

Binary file not shown.

3
frontend/.browserslistrc Normal file
View file

@ -0,0 +1,3 @@
> 1%
last 2 versions
not ie <= 8

14
frontend/.eslintrc.js Normal file
View file

@ -0,0 +1,14 @@
module.exports = {
root: true,
env: {
node: true
},
extends: ['plugin:vue/essential', '@vue/prettier'],
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off'
},
parserOptions: {
parser: 'babel-eslint'
}
}

21
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,21 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw*

5
frontend/.prettierrc Normal file
View file

@ -0,0 +1,5 @@
# .prettierrc
# trailingComma: "es5"
tabWidth: 2
semi: false
singleQuote: true

View file

@ -0,0 +1,54 @@
{
"projects": [
{
"id": "SdPO5j8y6",
"path": "/app",
"favorite": 1,
"type": "vue",
"name": "frontend",
"openDate": 1557166583987,
"widgets": [
{
"id": "5sGiztidd",
"definitionId": "org.vue.widgets.run-task",
"x": 0,
"y": 0,
"width": 2,
"height": 1,
"config": {
"task": "/app:serve"
},
"configured": true
},
{
"id": "SyF7UHhrK",
"definitionId": "org.vue.widgets.run-task",
"x": 0,
"y": 1,
"width": 2,
"height": 1,
"config": {
"task": "/app:lint"
},
"configured": true
}
]
}
],
"foldersFavorite": [],
"tasks": [
{
"id": "/app:serve",
"answers": {
"open": false,
"mode": "development",
"host": "0.0.0.0",
"port": "3000",
"https": false
}
}
],
"config": {
"lastOpenProject": "SdPO5j8y6"
}
}

21
frontend/Dockerfile Normal file
View file

@ -0,0 +1,21 @@
FROM node:lts AS dev
# some dir for our code
WORKDIR /app
RUN yarn global add @vue/cli @vue/cli-service-global
# mount code
VOLUME ["/app"]
# this is how we start
CMD [ "vue", "ui", "--host", "0.0.0.0", "--port", "8080" ]
FROM node:lts AS build
# some dir for our code
WORKDIR /app
# install dependencies
COPY package*.json yarn*.lock ./
RUN yarn --production=false
# copy code
COPY . .
RUN yarn build
FROM nginx:stable-alpine AS prod
COPY --from=build /app/dist /usr/share/nginx/html

34
frontend/README.md Normal file
View file

@ -0,0 +1,34 @@
# frontend
## Project setup
```
yarn install
```
### Compiles and hot-reloads for development
```
yarn run serve
```
### Compiles and minifies for production
```
yarn run build
```
### Run your tests
```
yarn run test
```
### Lints and fixes files
```
yarn run lint
```
### Run your unit tests
```
yarn run test:unit
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

3
frontend/babel.config.js Normal file
View file

@ -0,0 +1,3 @@
module.exports = {
presets: ['@vue/app']
}

17
frontend/jest.config.js Normal file
View file

@ -0,0 +1,17 @@
module.exports = {
moduleFileExtensions: ['js', 'jsx', 'json', 'vue'],
transform: {
'^.+\\.vue$': 'vue-jest',
'.+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$':
'jest-transform-stub',
'^.+\\.jsx?$': 'babel-jest'
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1'
},
snapshotSerializers: ['jest-serializer-vue'],
testMatch: [
'**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)'
],
testURL: 'http://localhost/'
}

43
frontend/package.json Normal file
View file

@ -0,0 +1,43 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint",
"test:unit": "vue-cli-service test:unit"
},
"dependencies": {
"material-design-icons-iconfont": "^4.0.5",
"roboto-fontface": "*",
"vue": "^2.6.6",
"vue-router": "^3.0.1",
"vuetify": "^1.5.14"
},
"devDependencies": {
"@vue/cli-plugin-babel": "^3.4.0",
"@vue/cli-plugin-eslint": "^3.4.0",
"@vue/cli-plugin-unit-jest": "^3.4.0",
"@vue/cli-service": "^3.4.0",
"@vue/eslint-config-prettier": "^4.0.1",
"@vue/test-utils": "^1.0.0-beta.20",
"axios": "^0.18.0",
"babel-core": "7.0.0-bridge.0",
"babel-eslint": "^10.0.1",
"babel-jest": "^24.8.0",
"coffee-loader": "^0.9.0",
"coffeescript": "^2.3.2",
"craftyjs": "^0.9.0",
"eslint": "^5.8.0",
"eslint-plugin-vue": "^5.0.0",
"js-cookie": "^2.2.0",
"stylus": "^0.54.5",
"stylus-loader": "^3.0.1",
"vue-async-computed": "^3.6.1",
"vue-cli-plugin-coffeescript": "^0.0.3",
"vue-cli-plugin-vuetify": "^0.5.0",
"vue-template-compiler": "^2.5.21",
"vuetify-loader": "^1.0.5"
}
}

View file

@ -0,0 +1,5 @@
module.exports = {
plugins: {
autoprefixer: {}
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,17 @@
<!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">
<title>frontend</title>
</head>
<body>
<noscript>
<strong>We're sorry but frontend 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>

11
frontend/src/App.vue Normal file
View file

@ -0,0 +1,11 @@
<template>
<v-app>
<router-view />
</v-app>
</template>
<script>
export default {
name: 'App'
}
</script>

View file

@ -0,0 +1,36 @@
<template>
<v-app>
<v-toolbar app>
<v-toolbar-title class="headline text-uppercase">
<span>Vuetify</span>
<span class="font-weight-light">MATERIAL DESIGN</span>
</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn flat :to="{ path: '/' }">Home</v-btn>
<v-btn flat :to="{ path: '/about' }">About</v-btn>
<v-btn
flat
href="https://github.com/vuetifyjs/vuetify/releases/latest"
target="_blank"
>
<span class="mr-2">Latest Release</span>
<v-icon>open_in_new</v-icon>
</v-btn>
</v-toolbar>
<v-content>
<router-view />
</v-content>
</v-app>
</template>
<script>
export default {
name: 'ExampleApp',
data() {
return {
//
}
}
}
</script>

View file

Before

Width:  |  Height:  |  Size: 610 KiB

After

Width:  |  Height:  |  Size: 610 KiB

View file

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 87.5 100"><defs><style>.cls-1{fill:#1697f6;}.cls-2{fill:#7bc6ff;}.cls-3{fill:#1867c0;}.cls-4{fill:#aeddff;}</style></defs><title>Artboard 46</title><polyline class="cls-1" points="43.75 0 23.31 0 43.75 48.32"/><polygon class="cls-2" points="43.75 62.5 43.75 100 0 14.58 22.92 14.58 43.75 62.5"/><polyline class="cls-3" points="43.75 0 64.19 0 43.75 48.32"/><polygon class="cls-4" points="64.58 14.58 87.5 14.58 43.75 100 43.75 62.5 64.58 14.58"/></svg>

After

Width:  |  Height:  |  Size: 539 B

View file

Before

Width:  |  Height:  |  Size: 615 KiB

After

Width:  |  Height:  |  Size: 615 KiB

View file

@ -0,0 +1,212 @@
'use strict'
import CardsDB from '@/plugins/ffdecks'
export default class {
constructor(id) {
this.id = id
this.name = ''
this.note = ''
this.cards = []
}
populate() {
for (let card of this.cards) {
card.dbentry = CardsDB[card.serial]
}
}
from_object(obj) {
if (obj) {
this.name = obj.name
this.note = obj.note
this.cards = obj.cards
}
}
plainObject() {
let plainCards = []
for (let card of this.cards) {
plainCards.push({
serial: card.serial,
count: card.count
})
}
return {
name: this.name,
note: this.note,
cards: plainCards
}
}
from_deckList(str) {
// select all lines containing card serial numbers
let cardLinesRE = /^.*\b\d+-0*\d{1,3}[A-Z]?\b.*$/gm
let cardLines = str.match(cardLinesRE)
let cardCounts = {}
if (cardLines) {
for (let cardLine of cardLines) {
// extract serial (guaranteed to be in here!)
let serialRE = /\b(\d+)-0*(\d{1,3})[A-Z]?\b/i
let serial = serialRE.exec(cardLine)
// force format 'x-xxx'
serial = `${serial[1]}-${serial[2].padStart(3, '0')}`
// strip out serial number
cardLine = cardLine.replace(serialRE, '')
let countREs = [
// prioritize a count with "times" symbol *, x, ×
/\b([0-9]+)(?:[*×]|[x]\b)/,
/(?:[*×]|\b[x])([0-9]+)\b/,
// next priority: count with whitespace
/\s+([0-9]+)\s+/,
/\s+([0-9]+)\b/,
/\b([0-9]+)\s+/,
// least priority: any simple number
/\b([0-9]+)\b/
]
// fallback value
let count = 1
for (let countRE of countREs) {
let data = countRE.exec(cardLine)
if (data) {
count = Number(data[1])
break
}
}
// count copies
if (!cardCounts[serial]) {
cardCounts[serial] = 0
}
cardCounts[serial] += count
}
}
// push card data into deck
this.cards = []
for (let serial in cardCounts) {
this.cards.push({
serial: serial,
count: cardCounts[serial]
})
}
// strip out lines with serial numbers
str = str.replace(cardLinesRE, '')
// then strip out anything after the first empty line
str = str.replace(/^[\s]*$[^]*/m, '')
// select the line containing 'deck name:'
// and its successor (ffdecks format)
let metaRE = /^Deck Name: (.+)$[\s]*?^(.+)$/m
let metaData = metaRE.exec(str)
// fallback
this.name = 'Unnamed Deck'
this.note = ''
if (!metaData) {
// use lax format: <anything>:[deck name][newline][note]
metaRE = /[^]*?:(.+)$[\s]*?^([^]*)/m
metaData = metaRE.exec(str)
}
// look again, I am not an else!
if (metaData) {
// extract matches
this.name = metaData[1].trim()
this.note = metaData[2].trim()
}
}
parts() {
let retval = ['Forwards', 'Backups', 'Summons, Monsters & more'].map(
item => ({
heading: item,
cards: [],
count: 0
})
)
for (let card of this.cards) {
let target
switch (card.dbentry.type) {
case 'Forward':
target = 0
break
case 'Backup':
target = 1
break
default:
target = 2
break
}
retval[target].cards.push(card)
retval[target].count += card.count
}
for (let part of retval) {
part.cards.sort(
(card_l, card_r) => card_l.dbentry.cost - card_r.dbentry.cost
)
}
return retval
}
deckList() {
// empty deck is empty
if (this.count() == 0) return ''
let lines = []
// begin with deck name and note
lines.push('Deck Name: ' + this.name)
lines.push(this.note)
lines.push('')
// list each deck part
for (let part of this.parts()) {
lines.push(`${part.heading} (${part.count}):`)
for (let card of part.cards)
lines.push(`${card.count}x ${card.serial} "${card.dbentry.name}"`)
lines.push('')
}
return lines.join('\n')
}
count() {
return this.cards.reduce((total, card) => total + card.count, 0)
}
elements() {
let elements = {}
for (let card of this.cards) {
if (!elements[card.dbentry.element]) elements[card.dbentry.element] = 0
elements[card.dbentry.element] += card.count
}
let retval = []
for (let element in elements)
retval.push({
name: element,
count: elements[element]
})
retval.sort((element_l, element_r) => element_r.count - element_l.count)
return retval
}
}

View file

@ -0,0 +1,63 @@
<template>
<v-list-tile avatar>
<v-tooltip @input="booted = true" bottom>
<template v-slot:activator="{ on }">
<v-list-tile-avatar v-on="on" style="cursor: zoom-in">
<Crystal :size="35" :element="dbentry.element" :cost="dbentry.cost" />
</v-list-tile-avatar>
</template>
<v-img
v-if="booted"
:src="ffiurl"
:height="300"
:width="0.715 * 300"
contain
/>
</v-tooltip>
<v-list-tile-content>
<v-list-tile-title class="body-2">{{ dbentry.name }}</v-list-tile-title>
<v-list-tile-sub-title>{{ full_serial }}</v-list-tile-sub-title>
</v-list-tile-content>
<v-list-tile-avatar>
<span class="subheading">{{ count }}</span>
</v-list-tile-avatar>
</v-list-tile>
</template>
<script>
import Crystal from './Crystal.vue'
export default {
name: 'Card',
components: {
Crystal
},
props: {
count: Number,
serial: String,
dbentry: Object
},
data: () => ({
booted: false
}),
computed: {
full_serial() {
return this.serial + this.dbentry.rarity[0]
},
ffiurl() {
return (
'https://fftcg.square-enix-games.com/theme/tcg/images/cards/full/' +
this.full_serial +
'_eg.jpg'
)
}
}
}
</script>

View file

@ -0,0 +1,71 @@
<template>
<svg
:height="size + 'px'"
:width="(16 / 30) * size + 'px'"
viewBox="0 0 16 30"
xml:space="preserve"
>
<polygon points="0,5 8,0 16,5 16,25 8,30 0,25" :style="{ fill: color }" />
<polygon points="7,5 7,25 2,25 2,5" style="fill:rgba(255,255,255,0.3);" />
<polygon points="0,5 8,0 16,5" style="fill:rgba(255,255,255,0.6);" />
<polygon points="10,25 16,25 8,30" style="fill:rgba(255,255,255,0.5);" />
<text
x="50%"
text-anchor="middle"
y="17"
dominant-baseline="middle"
font-size="20"
font-family="sans-serif"
fill="white"
font-weight="bold"
transform-origin="center center"
:transform="`scale(${this.xscale},1)`"
>
{{ cost }}
</text>
</svg>
</template>
<script>
export default {
name: 'Crystal',
props: {
element: String,
size: Number,
cost: Number
},
computed: {
color() {
switch (this.element.toLowerCase()) {
case 'fire':
return '#d41'
case 'ice':
return '#7ac'
case 'wind':
return '#596'
case 'earth':
return '#db1'
case 'lightning':
return '#859'
case 'water':
return '#57a'
case 'light':
return '#888'
case 'dark':
default:
return '#333'
}
},
xscale() {
if (typeof this.cost !== 'undefined') {
let len = this.cost.toString().length
return Math.pow(0.7, len - 1)
}
return 1
}
}
}
</script>

View file

@ -0,0 +1,155 @@
<template>
<v-expansion-panel-content>
<template v-slot:header>
<v-layout align-center row>
<v-flex text-xs-center xs4 sm3 md2>
<Crystal
v-for="element in deck.elements()"
:size="40"
:cost="element.count"
:element="element.name"
:key="element.name"
/>
</v-flex>
<v-flex class="subheading">
<v-card flat>
<v-card-text>
{{ deck.name }}
</v-card-text>
</v-card>
</v-flex>
</v-layout>
</template>
<template v-if="!editing">
<v-alert :value="deck.note" type="info">
{{ deck.note }}
</v-alert>
<v-container style="position: relative" grid-list-md fluid>
<v-layout row wrap>
<v-flex v-for="part in deck.parts()" :key="part.heading" xs12 sm6 md4>
<v-card>
<v-card-title>{{ part.count }} {{ part.heading }}</v-card-title>
<v-list dense subheader>
<Card
v-for="card in part.cards"
:key="card.serial"
:count="card.count"
:serial="card.serial"
:dbentry="card.dbentry"
></Card>
</v-list>
</v-card>
</v-flex>
<v-btn fab absolute bottom right @click.native="editing = true">
<v-icon>edit</v-icon>
</v-btn>
<v-dialog v-model="deleting">
<template v-slot:activator="{ on }">
<v-btn fab absolute bottom left v-on="on">
<v-icon>delete</v-icon>
</v-btn>
</template>
<v-card>
<v-card-title class="headline">
Really delete this deck?
</v-card-title>
<v-card-text>
Are you sure you want to delete your deck "{{ deck.name }}"?
This cannot be undone.
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="error" @click.native="deleting = false">
Cancel
</v-btn>
<v-btn
color="success"
@click.native="
deleting = false
delete_deck()
"
>
Confirm
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-layout>
</v-container>
</template>
<DeckEditor v-if="editing" :deck="deck" @save="save_deck">
<v-btn color="error" @click.native="editing = false">
<v-icon>cancel</v-icon>
cancel
</v-btn>
</DeckEditor>
</v-expansion-panel-content>
</template>
<script>
import axios from '@/plugins/axios'
import Card from './Card.vue'
import Crystal from './Crystal.vue'
import DeckEditor from './forms/DeckEditor.vue'
export default {
name: 'Deck',
props: {
session: String,
deck: Object
},
components: {
Card,
Crystal,
DeckEditor
},
data: () => ({
editing: false,
deleting: false
}),
methods: {
save_deck(new_deck) {
axios
.post('/decks/modify', {
session: this.session,
deckID: this.deck.id,
deckCards: new_deck.plainObject()
})
.then(response => {
if (response.data.success) {
this.editing = false
this.$emit('change')
}
})
},
delete_deck() {
axios
.post('/decks/delete', {
session: this.session,
deckID: this.deck.id
})
.then(response => {
if (response.data.success) {
this.$emit('change')
}
})
}
}
}
</script>

View file

@ -0,0 +1,85 @@
<template>
<v-expansion-panel v-model="open">
<NewDeck
:session="session"
@change="
open = null
refresh_decks()
"
/>
<Deck
v-for="deck in linked"
:deck="deck"
:session="session"
:key="deck.id"
@change="refresh_decks"
/>
</v-expansion-panel>
</template>
<script>
import axios from '@/plugins/axios'
import DeckJS from '@/classes/Deck'
import Deck from './Deck.vue'
import NewDeck from './NewDeck.vue'
export default {
name: 'DeckList',
components: {
Deck,
NewDeck
},
props: {
session: String
},
data: () => ({
open: null
}),
asyncComputed: {
decks: {
get() {
return axios
.post('/decks/list', {
session: this.session
})
.then(response => {
if (response.data.success) {
return response.data.decks
}
})
},
default: []
}
},
computed: {
linked() {
let result = []
for (let plainDeck of this.decks) {
let deck = new DeckJS(plainDeck.id)
deck.from_object(plainDeck.content)
deck.populate()
result.push(deck)
}
result.sort((deck_l, deck_r) => deck_l.name.localeCompare(deck_r.name))
return result
}
},
methods: {
refresh_decks() {
this.$asyncComputed.decks.update()
}
}
}
</script>

View file

@ -0,0 +1,36 @@
<template>
<div>
<div v-for="game in games" :key="game">
{{ game }}
</div>
</div>
</template>
<script>
import axios from '@/plugins/axios'
export default {
name: 'GamesList',
props: {
session: String
},
asyncComputed: {
games: {
get() {
return axios
.post('/games/list', {
session: this.session
})
.then(response => {
if (response.data.success) {
return response.data.games
}
})
},
default: []
}
}
}
</script>

View file

@ -0,0 +1,16 @@
<template>
<v-toolbar app>
<v-toolbar-title class="headline text-uppercase">
<span>Yavook</span>
<span class="font-weight-light">!FFTCG</span>
</v-toolbar-title>
<v-spacer></v-spacer>
<slot />
</v-toolbar>
</template>
<script>
export default {
name: 'Header'
}
</script>

View file

@ -0,0 +1,116 @@
<template>
<Header v-if="user">
<v-btn flat :to="{ name: 'deckcp' }">
<v-icon>view_carousel</v-icon> Decks
</v-btn>
<v-btn flat :to="{ name: 'games' }">
<v-icon>play_arrow</v-icon> Play
</v-btn>
<v-btn flat :to="{ name: 'usercp' }">
<v-icon>person</v-icon> {{ user.login }}
</v-btn>
<v-dialog v-model="logging_out">
<template v-slot:activator="{ on }">
<v-btn flat v-on="on"> <v-icon>power_off</v-icon> Logout </v-btn>
</template>
<v-card>
<v-card-title class="headline">
Log Out?
</v-card-title>
<v-card-text>
Are you sure you want to log out?
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="error" @click.native="logging_out = false">
Cancel
</v-btn>
<v-btn color="success" @click.native="logout">
Confirm
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</Header>
</template>
<script>
import * as Cookies from 'js-cookie'
import axios from 'axios'
import Header from './Header'
export default {
name: 'HeaderIntern',
components: {
Header
},
data: () => ({
logging_out: false
}),
props: {
value: String
},
methods: {
goHome() {
this.$emit('input', '')
Cookies.remove('session')
this.$router.push({ name: 'home' })
},
logout() {
axios
.post('/user/logout', {
session: this.value
})
.then(response => {
if (response.data.success) {
this.goHome()
}
})
}
},
computed: {
session: () => Cookies.get('session')
},
asyncComputed: {
user: {
get() {
return axios
.post('/user/info', {
session: this.session
})
.then(response => {
if (response.data.success) {
this.$emit('input', this.session)
this.$emit('user', response.data.user)
return response.data.user
} else {
this.goHome()
return null
}
})
},
default: {
user: 0,
login: '',
settings: ''
}
}
}
}
</script>

View file

@ -0,0 +1,134 @@
<template>
<v-container>
<v-layout text-xs-center wrap>
<v-flex xs12>
<v-img
:src="require('../assets/logo.svg')"
class="my-3"
contain
height="200"
></v-img>
</v-flex>
<v-flex mb-4>
<h1 class="display-2 font-weight-bold mb-3">
Welcome to Vuetify
</h1>
<p class="subheading font-weight-regular">
For help and collaboration with other Vuetify developers,
<br />please join our online
<a href="https://community.vuetifyjs.com" target="_blank"
>Discord Community</a
>
</p>
</v-flex>
<v-flex mb-5 xs12>
<h2 class="headline font-weight-bold mb-3">What's next?</h2>
<v-layout justify-center>
<a
v-for="(next, i) in whatsNext"
:key="i"
:href="next.href"
class="subheading mx-3"
target="_blank"
>
{{ next.text }}
</a>
</v-layout>
</v-flex>
<v-flex xs12 mb-5>
<h2 class="headline font-weight-bold mb-3">Important Links</h2>
<v-layout justify-center>
<a
v-for="(link, i) in importantLinks"
:key="i"
:href="link.href"
class="subheading mx-3"
target="_blank"
>
{{ link.text }}
</a>
</v-layout>
</v-flex>
<v-flex xs12 mb-5>
<h2 class="headline font-weight-bold mb-3">Ecosystem</h2>
<v-layout justify-center>
<a
v-for="(eco, i) in ecosystem"
:key="i"
:href="eco.href"
class="subheading mx-3"
target="_blank"
>
{{ eco.text }}
</a>
</v-layout>
</v-flex>
</v-layout>
</v-container>
</template>
<script>
export default {
data: () => ({
ecosystem: [
{
text: 'vuetify-loader',
href: 'https://github.com/vuetifyjs/vuetify-loader'
},
{
text: 'github',
href: 'https://github.com/vuetifyjs/vuetify'
},
{
text: 'awesome-vuetify',
href: 'https://github.com/vuetifyjs/awesome-vuetify'
}
],
importantLinks: [
{
text: 'Documentation',
href: 'https://vuetifyjs.com'
},
{
text: 'Chat',
href: 'https://community.vuetifyjs.com'
},
{
text: 'Made with Vuetify',
href: 'https://madewithvuejs.com/vuetify'
},
{
text: 'Twitter',
href: 'https://twitter.com/vuetifyjs'
},
{
text: 'Articles',
href: 'https://medium.com/vuetify'
}
],
whatsNext: [
{
text: 'Explore components',
href: 'https://vuetifyjs.com/components/api-explorer'
},
{
text: 'Select a layout',
href: 'https://vuetifyjs.com/layout/pre-defined'
},
{
text: 'Frequently Asked Questions',
href: 'https://vuetifyjs.com/getting-started/frequently-asked-questions'
}
]
})
}
</script>
<style></style>

View file

@ -0,0 +1,57 @@
<template>
<v-expansion-panel-content>
<template v-slot:header>
<v-layout align-center row>
<v-flex text-xs-center xs4 sm3 md2>
<v-icon>view_carousel</v-icon>
<v-icon>add</v-icon>
</v-flex>
<v-flex class="subheading">
<v-card flat>
<v-card-text>
New Deck
</v-card-text>
</v-card>
</v-flex>
</v-layout>
</template>
<v-card flat>
<DeckEditor ref="editor" @save="save_deck" />
</v-card>
</v-expansion-panel-content>
</template>
<script>
import axios from '@/plugins/axios'
import DeckEditor from './forms/DeckEditor.vue'
export default {
name: 'NewDeck',
components: {
DeckEditor
},
props: {
session: String
},
methods: {
save_deck(new_deck) {
axios
.post('/decks/add', {
session: this.session,
deckCards: new_deck.plainObject()
})
.then(response => {
if (response.data.success) {
this.$emit('change')
this.$refs.editor.clear()
}
})
}
}
}
</script>

View file

@ -0,0 +1,16 @@
<template>
<div>
{{ user.login }}
</div>
</template>
<script>
export default {
name: 'UserInfo',
props: {
session: String,
user: Object
}
}
</script>

View file

@ -0,0 +1,118 @@
<template>
<v-container>
<v-card flat>
<v-alert :value="check.count !== 50" type="warning">
{{ check.count }} cards detected! (Decks should have exactly 50 cards)
</v-alert>
<v-alert :value="check.maximum > 3" type="warning">
Card with {{ check.maximum }} copies detected! (Cards should not have
more than 3 copies)
</v-alert>
<v-textarea
ref="deckList"
:label="
`${
typeof deck !== 'undefined' && deck !== null ? 'Edit' : 'Paste'
} Decklist`
"
rows="35"
:hint="
`ffdecks.com format One card per line Include quantity and serial (e.g. ${format_sample})`
"
style="font-family: monospace"
:value="new_deck.deckList()"
@input="check.checked = false"
>
</v-textarea>
<v-card-actions>
<slot></slot>
<v-spacer></v-spacer>
<v-btn color="info" @click.native="validate" :disabled="check.checked">
<v-icon>check</v-icon>
validate
</v-btn>
<v-btn color="success" @click.native="save" :disabled="!check.checked">
<v-icon>save</v-icon>
save
</v-btn>
</v-card-actions>
</v-card>
</v-container>
</template>
<script>
import Deck from '@/classes/Deck'
import CardsDB from '@/plugins/ffdecks'
export default {
name: 'DeckEditor',
props: {
deck: Object
},
data: () => ({
check: null,
new_deck: null
}),
computed: {
format_sample() {
let serials = []
for (let tmp_key in CardsDB) {
if (CardsDB.hasOwnProperty(tmp_key)) {
serials.push(tmp_key)
}
}
let quantity = Math.floor(Math.random() * 3) + 1
let serial = serials[Math.floor(Math.random() * serials.length)]
return `${quantity}x ${serial}`
}
},
created() {
this.clear()
},
methods: {
clear() {
this.check = {
count: 50,
maximum: 0,
checked: false
}
this.new_deck = new Deck(0)
if (this.deck)
// this.deck should already be populated!
this.new_deck.from_object(this.deck)
},
validate() {
this.new_deck.from_deckList(this.$refs.deckList.lazyValue)
this.new_deck.populate()
// count number of cards
this.check.count = this.new_deck.count()
// find most frequent card
this.check.maximum = 0
for (let card of this.new_deck.cards) {
if (card.count > this.check.maximum) this.check.maximum = card.count
}
// deck has now been checked
this.check.checked = true
},
save() {
this.$emit('save', this.new_deck)
}
}
}
</script>

View file

@ -0,0 +1,99 @@
<template>
<v-dialog v-model="dialog">
<template v-slot:activator="{ on }">
<v-btn flat v-on="on">
<v-icon>{{ icon }}</v-icon> {{ buttonText }}
</v-btn>
</template>
<v-card>
<v-snackbar
v-model="snackbar.visible"
:timeout="6000"
:color="snackbar.color"
absolute
top
>
{{ snackbar.text }}
<v-btn @click.native="snackbar.visible = false" fab flat icon>
<v-icon>close</v-icon>
</v-btn>
</v-snackbar>
<v-form
ref="form"
v-model="valid"
@submit.prevent="validate"
lazy-validation
>
<slot></slot>
<v-divider></v-divider>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="success" type="submit" :disabled="!valid">
{{ buttonText }}
</v-btn>
<v-btn color="error" @click.native="dialog = false">
Close
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</v-dialog>
</template>
<script>
export default {
name: 'FormDialog',
data: () => ({
dialog: false,
valid: true,
snackbar: {
visible: false,
color: '',
text: ''
}
}),
props: {
buttonText: String,
icon: String
},
methods: {
validate() {
if (this.$refs.form.validate()) {
this.$emit('validated')
}
},
showSnackbar(text, color) {
if (text == '') return
this.snackbar.visible = false
window.setTimeout(() => {
this.snackbar.text = text
this.snackbar.color = color
this.snackbar.visible = true
}, 100)
}
},
watch: {
dialog(val) {
if (val) {
requestAnimationFrame(() => {
this.$parent.$refs.autofocus.focus()
})
} else {
this.$refs.form.resetValidation()
}
}
}
}
</script>

View file

@ -0,0 +1,75 @@
<template>
<FormDialog ref="main" buttonText="Login" icon="power" @validated="doLogin">
<v-card-title class="headline">
Log In
</v-card-title>
<v-card-text>
<v-text-field
ref="autofocus"
v-model="login"
:rules="loginRules"
label="User name"
required
></v-text-field>
<v-text-field
v-model="password"
:rules="passwordRules"
:append-icon="showPassword ? 'visibility' : 'visibility_off'"
@click:append="showPassword = !showPassword"
:type="showPassword ? 'text' : 'password'"
label="Password"
required
></v-text-field>
</v-card-text>
</FormDialog>
</template>
<script>
import FormDialog from './FormDialog.vue'
import * as Cookies from 'js-cookie'
import axios from '@/plugins/axios'
export default {
name: 'LoginForm',
components: {
FormDialog
},
props: {
session: String
},
data: () => ({
login: '',
loginRules: [v => !!v || 'Please enter user name'],
password: '',
showPassword: false,
passwordRules: [v => !!v || 'Please enter password']
}),
methods: {
doLogin() {
axios
.post('/user/login', {
session: this.session,
login: this.login,
password: this.password
})
.then(response => {
if (response.data.success) {
let cookie_data = JSON.parse(response.data.message)
Cookies.set('session', cookie_data.value, cookie_data.properties)
this.$refs.main.showSnackbar('Login successful!', 'success')
this.$router.push('deckcp')
} else {
this.$refs.main.showSnackbar(response.data.message, 'error')
}
})
}
}
}
</script>

View file

@ -0,0 +1,92 @@
<template>
<FormDialog
ref="main"
buttonText="Register"
icon="book"
@validated="doRegister"
>
<v-card-title class="headline">
Register
</v-card-title>
<v-card-text>
<v-text-field
ref="autofocus"
v-model="login"
:rules="loginRules"
label="User name"
required
></v-text-field>
<v-text-field
v-model="password"
:append-icon="showPassword ? 'visibility' : 'visibility_off'"
@click:append="showPassword = !showPassword"
:rules="passwordRules"
:type="showPassword ? 'text' : 'password'"
label="Password"
required
counter
></v-text-field>
<v-text-field
v-if="!showPassword"
v-model="passwordConfirm"
:rules="passwordConfirmRules"
:type="showPassword ? 'text' : 'password'"
label="Confirm Password"
required
></v-text-field>
</v-card-text>
</FormDialog>
</template>
<script>
import FormDialog from './FormDialog.vue'
import axios from '@/plugins/axios'
export default {
name: 'RegisterForm',
components: {
FormDialog
},
data: () => ({
login: '',
loginRules: [v => !!v || 'User name is required'],
password: '',
showPassword: false,
passwordRules: [v => !!v || 'Password is required'],
passwordConfirm: ''
}),
computed: {
passwordConfirmRules() {
return [
() => this.password === this.passwordConfirm || 'Passwords must match'
]
}
},
methods: {
doRegister() {
axios
.post('/user/register', {
session: null,
login: this.login,
password: this.password
})
.then(response => {
if (response.data.success) {
this.$refs.main.showSnackbar('Registration successful!', 'success')
} else {
this.$refs.main.showSnackbar(response.data.message, 'error')
}
})
}
}
}
</script>

View file

@ -0,0 +1,49 @@
# base scene
require '../crafty/scenes/Battle.coffee'
Crafty.scene "Battle"
# annoying shantotto
Crafty.sprite 480, 670, '//www.fftcgmognet.com/images/cards/hd/1/1/107.jpg',
shantotto: [
0
0
]
# create cards
require '../crafty/components/Card.coffee'
Crafty.e 'shantotto, AllyCard'
.attr {
card:
type: 'backup'
}
Crafty.e 'shantotto, AllyCard'
.attr {
card:
type: 'backup'
}
Crafty.e 'shantotto, AllyCard'
.attr {
card:
type: 'backup'
}
# place cards
CONF = require '../crafty/config.coffee'
Crafty 'AllyCard'
.each (index) ->
switch @card.type
when 'backup'
@trigger 'Place',
x: CONF.coord.x.main + index * CONF.coord.x.step
y: CONF.coord.y.bkup
return
Crafty.e 'shantotto, EnemyCard'
.trigger 'Place',
x: 900
y: 0

View file

@ -1,3 +1,4 @@
CONF = require '../config.coffee'
# intermediate config # intermediate config
bcConf = Crafty.clone CONF.bigcard bcConf = Crafty.clone CONF.bigcard

View file

@ -1,3 +1,4 @@
CONF = require '../config.coffee'
require './BigCard.coffee' require './BigCard.coffee'
################ ################

View file

@ -1,3 +1,5 @@
CONF = require '../config.coffee'
################ ################
# Playmat # Playmat
################ ################

View file

@ -1,4 +1,4 @@
window.CONF = module.exports =
playmat: playmat:
w: 2000 w: 2000

View file

@ -1,3 +1,4 @@
CONF = require '../config.coffee'
require '../components/Playmat.coffee' require '../components/Playmat.coffee'
Crafty.defineScene "Battle", -> Crafty.defineScene "Battle", ->
@ -32,7 +33,7 @@ Crafty.defineScene "Battle", ->
Crafty.trigger 'ViewportResize' Crafty.trigger 'ViewportResize'
# Example playmats at https://imgur.com/a/VSosu#cwGQdAS # Example playmats at https://imgur.com/a/VSosu#cwGQdAS
Crafty.sprite 2000, 1000, 'assets/ff7.jpg', Crafty.sprite 2000, 1000, require('@/assets/ff7.jpg'),
playmat: [ playmat: [
0 0
0 0

14
frontend/src/main.js Normal file
View file

@ -0,0 +1,14 @@
import Vue from 'vue'
import './plugins/vuetify'
import './plugins/vue-async-computed'
import App from './App.vue'
import router from './router'
import 'roboto-fontface/css/roboto/roboto-fontface.css'
import 'material-design-icons-iconfont/dist/material-design-icons.css'
Vue.config.productionTip = false
new Vue({
router,
render: h => h(App)
}).$mount('#app')

View file

@ -0,0 +1,5 @@
import axios from 'axios'
axios.defaults.baseURL =
window.location.protocol + '//' + window.location.hostname + ':3001'
export default axios

View file

@ -0,0 +1,9 @@
import oldCards from './ffdecks.json'
let CardsDB = {}
for (var i = 0; i < oldCards.cards.length; i++) {
CardsDB[oldCards.cards[i].serial_number] = oldCards.cards[i]
}
export default CardsDB

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,4 @@
import Vue from 'vue'
import AsyncComputed from 'vue-async-computed'
Vue.use(AsyncComputed)

View file

@ -0,0 +1,22 @@
import Vue from 'vue'
import Vuetify from 'vuetify/lib'
import 'vuetify/src/stylus/app.styl'
import en from 'vuetify/es5/locale/en'
Vue.use(Vuetify, {
theme: {
primary: '#ee44aa',
secondary: '#424242',
accent: '#82B1FF',
error: '#FF5252',
info: '#2196F3',
success: '#4CAF50',
warning: '#FFC107'
},
// customProperties: true,
iconfont: 'md',
lang: {
locales: { en },
current: 'en'
}
})

43
frontend/src/router.js Normal file
View file

@ -0,0 +1,43 @@
import Vue from 'vue'
import Router from 'vue-router'
import Home from './views/Home.vue'
Vue.use(Router)
export default new Router({
mode: 'history',
base: process.env.BASE_URL,
routes: [
{
path: '/',
name: 'home',
component: Home
},
{
path: '/usercp',
name: 'usercp',
// route level code-splitting
// this generates a separate chunk (usercp.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () =>
import(/* webpackChunkName: "usercp" */ './views/UserCP.vue')
},
{
path: '/deckcp',
name: 'deckcp',
component: () =>
import(/* webpackChunkName: "deckcp" */ './views/DeckCP.vue')
},
{
path: '/games',
name: 'games',
component: () =>
import(/* webpackChunkName: "games" */ './views/Games.vue')
},
{
path: '/game',
name: 'game',
component: () => import(/* webpackChunkName: "game" */ './views/Game.vue')
}
]
})

View file

@ -0,0 +1,29 @@
<template>
<v-content>
<HeaderIntern v-model="session" @user="user = $event" />
<v-container v-if="user">
<h2 class="headline">Your Decks</h2>
<DeckList :session="session" />
</v-container>
</v-content>
</template>
<script>
import HeaderIntern from '@/components/HeaderIntern.vue'
import DeckList from '@/components/DeckList.vue'
export default {
name: 'DeckCP',
components: {
HeaderIntern,
DeckList
},
data: () => ({
session: null,
user: null
})
}
</script>

View file

@ -0,0 +1,32 @@
<template lang="html">
<div ref="game" />
</template>
<script>
export default {
name: 'Game',
mounted() {
this.$nextTick(function() {
try {
// framework
require('craftyjs/dist/crafty')
// initialize on ref="game"
window.Crafty.init(this.$refs.game)
window.Crafty.stage.fullscreen = true
// load fftcg
require('@/crafty/Game.coffee')
} catch (e) {
window.location.reload()
}
})
},
beforeDestroy() {
window.Crafty.stop(true)
delete window.Crafty
}
}
</script>

View file

@ -0,0 +1,29 @@
<template>
<v-content>
<HeaderIntern v-model="session" @user="user = $event" />
<v-container v-if="user">
<h2 class="headline">Open Tables</h2>
<GamesList :session="session" />
</v-container>
</v-content>
</template>
<script>
import HeaderIntern from '@/components/HeaderIntern.vue'
import GamesList from '@/components/GamesList.vue'
export default {
name: 'Games',
components: {
HeaderIntern,
GamesList
},
data: () => ({
session: null,
user: null
})
}
</script>

View file

@ -0,0 +1,53 @@
<template>
<v-content>
<v-container>
<Header>
<LoginForm :session="session" />
<RegisterForm />
</Header>
<p class="subheading font-weight-regular">
App under development, please don't submit any valuable data!
</p>
</v-container>
</v-content>
</template>
<script>
import * as Cookies from 'js-cookie'
import axios from '@/plugins/axios'
import Header from '@/components/Header.vue'
import LoginForm from '@/components/forms/Login.vue'
import RegisterForm from '@/components/forms/Register.vue'
export default {
name: 'Home',
components: {
Header,
LoginForm,
RegisterForm
},
computed: {
session: () => Cookies.get('session')
},
mounted() {
this.$nextTick(() => {
if (this.session) {
axios
.post('/user/login', {
session: this.session
})
.then(response => {
if (response.data.success) {
this.$router.push({ name: 'deckcp' })
}
})
}
})
}
}
</script>

View file

@ -0,0 +1,29 @@
<template>
<v-content>
<HeaderIntern v-model="session" @user="user = $event" />
<v-container v-if="user">
<h2 class="headline">User Info</h2>
<UserInfo :session="session" :user="user" />
</v-container>
</v-content>
</template>
<script>
import HeaderIntern from '@/components/HeaderIntern.vue'
import UserInfo from '@/components/UserInfo.vue'
export default {
name: 'UserCP',
components: {
HeaderIntern,
UserInfo
},
data: () => ({
session: null,
user: null
})
}
</script>

View file

@ -0,0 +1,5 @@
module.exports = {
env: {
jest: true
}
}

View file

@ -0,0 +1,12 @@
import { shallowMount } from '@vue/test-utils'
import HelloWorld from '@/components/HelloWorld.vue'
describe('HelloWorld.vue', () => {
it('renders props.msg when passed', () => {
const msg = 'new message'
const wrapper = shallowMount(HelloWorld, {
propsData: { msg }
})
expect(wrapper.text()).toMatch(msg)
})
})

10434
frontend/yarn.lock Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,11 +0,0 @@
FFTCGLOG = (unit) ->
@unit = unit
return
FFTCGLOG::log = (msg) ->
console.log "[#{@unit}] #{msg}"
FFTCGLOG::error = (msg) ->
console.error "[#{@unit}] #{msg}"
module.exports = FFTCGLOG

View file

@ -1,222 +0,0 @@
# libraries
bcrypt = (require 'bcrypt')
sqlite3 = (require 'sqlite3').verbose()
FFTCGLOG = new (require './console')('FFTCGDB')
# bruteforce countermeasure
saltRounds = 13
FFTCGDB = (filename, truncate) ->
that = @
@filename = filename
@db = new sqlite3.Database @filename, (err) ->
if err
FFTCGLOG.error err.message
else
FFTCGLOG.log "Connected to '#{that.filename}'"
that.db.run 'PRAGMA foreign_keys = ON;', (err) ->
FFTCGLOG.error err.message if err
if truncate == true
that.db.run 'DROP TABLE IF EXISTS users;', (err) ->
FFTCGLOG.error err.message if err
that.db.run '''
CREATE TABLE users (
user integer PRIMARY KEY,
login text NOT NULL COLLATE NOCASE,
pwdhash text NOT NULL,
UNIQUE(login)
);
''', (err) ->
FFTCGLOG.error err.message if err
that.db.run 'DROP TABLE IF EXISTS decks;', (err) ->
FFTCGLOG.error err.message if err
that.db.run '''
CREATE TABLE decks (
deck integer PRIMARY KEY,
user integer NOT NULL,
json text,
FOREIGN KEY (user) REFERENCES users (user)
ON DELETE CASCADE
);
''', (err) ->
FFTCGLOG.error err.message if err
FFTCGLOG.log 'recreated DB'
return
FFTCGDB::close = ->
new Promise (resolve, reject) ->
@db.close (err) ->
if err
FFTCGLOG.log "Error closing: '#{err.message}'"
resolve 'ok'
else
FFTCGLOG.error "Closed '#{@filename}'"
reject 'db'
FFTCGDB::register = (login, password) ->
that = @
new Promise (resolve, reject) ->
# validate user input
if login == '' or password == ''
# no user name or password given
FFTCGLOG.log "reg: user name '#{login}' or password empty"
reject 'invalid'
# hash password
bcrypt.hash password, saltRounds, (err, hash) ->
if err
FFTCGLOG.log "reg: hash fail for name '#{login}'"
reject 'hash'
# try creating row in users table
stmt = that.db.prepare 'INSERT INTO users (login, pwdhash) VALUES (?, ?)'
stmt.run [login, hash], (err) ->
if err
FFTCGLOG.log "reg: DB fail '#{err.code}' for name '#{login}'"
stmt.finalize()
# reduce attack surface, don't disclose user names
reject 'db' # user already exists
else
FFTCGLOG.log "reg: OK '#{login}'"
stmt.finalize()
# registration successful
resolve
user: @lastID
login: login
FFTCGDB::login = (login, password) ->
that = @
new Promise (resolve, reject) ->
# get users table row
stmt = that.db.prepare 'SELECT user, login, pwdhash FROM users WHERE login = ?'
stmt.get [login], (err, row) ->
if err
FFTCGLOG.log "login: DB fail '#{err.code}' for name '#{login}'"
stmt.finalize()
reject 'db'
else if not row
# hash the password for timing attack reasons
bcrypt.hash password, saltRounds, (err, hash) ->
FFTCGLOG.log "login: nonexistent '#{login}'"
stmt.finalize()
# reduce attack surface, don't disclose user names
reject 'login' # user doesnt exist
else
bcrypt.compare password, row.pwdhash, (err, res) ->
if err
FFTCGLOG.log "login: hash fail for name '#{login}'"
reject 'hash'
if res == true
FFTCGLOG.log "login: OK '#{row.login}'"
stmt.finalize()
# login successful
resolve
user: row.user
login: row.login
else
FFTCGLOG.log "login: wrong password for '#{login}'"
stmt.finalize()
# login failed
reject 'login'
FFTCGDB::addDeck = (user, deckCards) ->
that = @
new Promise (resolve, reject) ->
# try creating row in decks table
stmt = that.db.prepare 'INSERT INTO decks (user, json) VALUES (?, ?)'
stmt.run [user, JSON.stringify deckCards], (err) ->
if err
FFTCGLOG.log "addDeck: DB fail '#{err.code}' for id '#{user}'"
stmt.finalize()
reject 'db'
else
stmt.finalize()
# deck added successfully, now add cards
that.modDeck(@lastID, deckCards)
.then (deckID) ->
resolve deckID
.catch (error) ->
reject error
FFTCGDB::modDeck = (deckID, deckCards) ->
that = @
new Promise (resolve, reject) ->
# delete old deck cards
stmt = that.db.prepare 'DELETE FROM decks_cards WHERE deck = ?'
stmt.run [deckID], (err) ->
stmt.finalize()
if err
FFTCGLOG.log "modDeck: DB fail '#{err.code}' for deck '#{deckID}'"
reject 'db'
else
stmt = that.db.prepare 'INSERT INTO decks_cards (deck, card, quant) VALUES (?, ?, ?)'
# add new cards
that.db.parallelize ->
# needs to be done in several queries
promiseCount = deckCards.length
deckCards.forEach (card) ->
stmt.run [deckID, card.id, card.quant], (err) ->
if err
FFTCGLOG.log "modDeck: DB fail '#{err.code}' for card '#{deckID}', '#{card.id}', '#{card.quant}'"
stmt.finalize()
reject 'db'
else
# check if all queries are done
promiseCount -= 1
if promiseCount == 0
FFTCGLOG.log "modDeck: OK '#{deckID}'"
stmt.finalize()
resolve deckID
FFTCGDB::getDecks = (user) ->
that = @
new Promise (resolve, reject) ->
# try deleting correct row in decks table
decks = {}
stmt = that.db.prepare 'SELECT decks.deck, decks.json FROM decks INNER JOIN users ON decks.user = users.user WHERE users.user = ?'
stmt.all [user], (err, rows) ->
stmt.finalize()
if err
FFTCGLOG.log "getDeck: DB fail '#{err.code}' for deck '#{deckID}'"
reject 'db'
else
FFTCGLOG.log "getDeck: OK '#{deckID}'"
for row in rows
decks[row.deck] = JSON.parse row.json
resolve decks
FFTCGDB::delDeck = (deckID) ->
that = @
new Promise (resolve, reject) ->
# try deleting correct row in decks table
stmt = that.db.prepare 'DELETE FROM decks WHERE deck = ?'
stmt.run [deckID], (err) ->
stmt.finalize()
if err
FFTCGLOG.log "delDeck: DB fail '#{err.code}' for deck '#{deckID}'"
reject 'db'
else
FFTCGLOG.log "delDeck: OK '#{deckID}'"
resolve deckID
module.exports = FFTCGDB

View file

@ -1,82 +0,0 @@
# node libraries
express = (require 'express')
path = (require 'path')
# my libraries
FFTCGDB = (require './db')
FFTCGLOG = new (require './console')('FFTCGROUTER')
# open fftcg db
fftcgdb = new FFTCGDB path.resolve(__dirname, '../fftcg.db')
# create router
FFTCGROUTER = express.Router()
# request logging
FFTCGROUTER.use (req, res, next) ->
if req.session.user
FFTCGLOG.log "user '#{req.session.user.login}' requested '#{req.url}'"
else
FFTCGLOG.log "requested '#{req.url}'"
next()
# static content
FFTCGROUTER.use express.static path.resolve(__dirname, '../public_html')
# register user
FFTCGROUTER.post '/register', (req, res) ->
fftcgdb.register req.body.login, req.body.password
.then (user) ->
# registration successful, return JSON status
res.json
status: 'ok'
user: user.user
login: user.login
.catch (err) ->
# registration failed, return JSON status
res.json
status: 'fail'
text: err
# log in user
FFTCGROUTER.post '/login', (req, res) ->
fftcgdb.login req.body.login, req.body.password
.then (user) ->
# login successful, save stuff in session
req.session.user = user
req.session.save()
# return JSON status
res.json
status: 'ok'
user: user.user
login: user.login
.catch (err) ->
# login failed, return JSON status
res.json
status: 'fail'
text: err
# Templates
FFTCGROUTER.get '/:template.html', (req, res) ->
# redirect logged-in users to user cp
if req.session.user and req.params.template == 'index'
return res.redirect '/usercp.html'
# render requested template
res.render (req.params.template + '.pug'), (err, html) ->
# redirect invalid requests to index
if err
return res.redirect '/index.html'
# actual response
res.send html
# default route
FFTCGROUTER.use (req, res) ->
return res.redirect '/index.html'
module.exports = FFTCGROUTER

View file

@ -1,22 +0,0 @@
# node libraries
expressSession = (require 'express-session')
RedisStore = (require 'connect-redis')(expressSession)
module.exports = (app) ->
session =
secret: 'keyboard cat'
store: new RedisStore
host: 'redis'
port: 6379
cookie:
httpOnly: true
sameSite: 'strict'
proxy: true
resave: true
saveUninitialized: true
if app.get 'env' == 'production'
app.set 'trust proxy', 1
session.cookie.secure = true
expressSession session

View file

@ -1,38 +0,0 @@
# node libraries
socketio = (require 'socket.io')
path = (require 'path')
FFTCGLOG = new (require './console')('FFTCGSOCKET')
# my libraries
FFTCGSOCKET = (http, session) ->
that = @
# create server socket
@io = socketio http
@io.use session
# on new connection
@io.on 'connection', (socket) ->
@session = socket.handshake.session
FFTCGLOG.log "session '#{@session.id}' connected"
FFTCGLOG.log "is user '#{@session.userID}'" if @session.userID
socket.on 'disconnect', ->
FFTCGLOG.log "session '#{that.session.id}' disconnected"
FFTCGLOG.log "is user '#{that.session.userID}'" if that.session.userID
return
FFTCGSOCKET::close = ->
FFTCGLOG.log 'shutting down'
if @db
@db.close()
.then (msg) ->
console.log msg
.catch (err) ->
console.error err
module.exports = FFTCGSOCKET

View file

@ -1,4 +0,0 @@
{
"verbose": true,
"watch": ["server.coffee", "inc/*"]
}

View file

@ -1,57 +0,0 @@
{
"name": "node-fftcg",
"version": "0.0.3",
"description": "FFTCG online using Socket.IO and CraftyJS on Node.js on Docker",
"author": "JMM <jmm@yavook.de>",
"main": "server.coffee",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "webpack",
"watch": "webpack --watch",
"start": "webpack && coffee server.coffee",
"debug": "webpack --watch & nodemon server.coffee",
"test": "echo \"Error: no test specified\" && exit 1"
},
"devDependencies": {
"@babel/core": "^7.1.6",
"@babel/preset-env": "^7.1.6",
"coffee-loader": "^0.9.0",
"autoprefixer": "^9.3.1",
"css-loader": "^1.0.1",
"postcss-loader": "^3.0.0",
"node-sass": "^4.10.0",
"precss": "^3.1.2",
"sass-loader": "^7.1.0",
"style-loader": "^0.23.1",
"nodemon": "^1.18.9",
"webpack": "^4.25.1",
"webpack-cli": "^3.1.2",
"bootstrap": "^4.1.3",
"craftyjs": "^0.9.0",
"jquery": "^3.3.1",
"popper.js": "^1.14.5"
},
"dependencies": {
"bcrypt": "^3.0.2",
"body-parser": "^1.18.3",
"coffeescript": "^2.3.2",
"connect-redis": "^3.4.0",
"express": "^4.16.4",
"express-session": "^1.15.6",
"express-socket.io-session": "^1.3.5",
"helmet": "^3.15.0",
"pug": "^2.0.3",
"socket.io": "^2.2.0",
"socket.io-client": "^2.2.0",
"sqlite3": "^4.0.4"
}
}

View file

@ -1,40 +0,0 @@
# node libraries
bodyParser = (require 'body-parser')
express = (require 'express')
sharedSession = (require 'express-socket.io-session')
helmet = (require 'helmet')
http = (require 'http')
path = (require 'path')
# my libraries
FFTCGSOCKET = (require './inc/socket')
FFTCGSESSION = (require './inc/session')
FFTCGROUTER = (require './inc/router')
FFTCGLOG = new (require './inc/console')('FFTCG')
# express framework
app = express()
app.use helmet()
app.use bodyParser.urlencoded
extended: true
# sessions
sessionMiddleware = FFTCGSESSION(app)
app.use sessionMiddleware
# routes
app.use FFTCGROUTER
# socket.io
web = http.Server app
socket = new FFTCGSOCKET web, sharedSession sessionMiddleware
# Create server
web.listen 3000, ->
FFTCGLOG.log 'Listening on port 3000 ...'
# Handle termination
process.on 'SIGINT', ->
socket.close()
FFTCGLOG.log 'shutting down after SIGINT'
process.exit()

View file

@ -1,71 +0,0 @@
# libs
window.$ = require('jquery')
# on load
$ ->
# libs requiring full DOM
require 'craftyjs/dist/crafty'
io = require 'socket.io-client'
# style sheet
require './style/custom.scss'
# fftcg libs
require './game/config.coffee'
require './game/components/Card.coffee'
require './game/scenes/Battle.coffee'
# init Socket.IO
socket = io()
# init CraftyJS framework
Crafty.init()
# Load base scene
Crafty.scene "Battle"
# Testing some entities
Crafty.sprite 480, 670, '//www.fftcgmognet.com/images/cards/hd/1/1/107.jpg',
shantotto: [
0
0
]
backups = [
Crafty.e 'shantotto, AllyCard'
.attr {
card:
type: 'backup'
}
Crafty.e 'shantotto, AllyCard'
.attr {
card:
type: 'backup'
}
Crafty.e 'shantotto, AllyCard'
.attr {
card:
type: 'backup'
}
]
Crafty 'AllyCard'
.each (index) ->
switch @card.type
when 'backup'
@trigger 'Place',
x: CONF.coord.x.main + index * CONF.coord.x.step
y: CONF.coord.y.bkup
return
Crafty.e 'shantotto, EnemyCard'
.trigger 'Place',
x: 900
y: 0
return

View file

@ -1,105 +0,0 @@
# libs
window.$ = require('jquery')
# import bootstrap
require './style/custom.scss'
require 'bootstrap/js/dist/alert'
window.showAlert = (level, content) ->
($ '.alert').alert 'close'
($ '#alert-area').append ($ '<div>',
class: "alert alert-#{level} alert-dismissible fade show"
role: 'alert'
.append content, ($ '<button>',
type: 'button'
class: 'close'
'data-dismiss': 'alert',
'aria-label': 'Close'
.append ($ '<span>',
'aria-hidden': 'true'
.append '&times;'
)))
# on load
$ ->
# reset forms
$('form').each ->
@fullReset = ->
$('input', @).each ->
$(@).removeClass 'is-invalid'
$(@).removeClass 'is-valid'
@reset()
# login form
$('form[name="login"]').submit (event) ->
that = @
# inhibit normal form submission
event.preventDefault()
# gather form data
login = $('input[name="login"]', @)
password = $('input[name="password"]', @)
# transmit form data
$.post '/login',
login: login.val()
password: password.val()
.done (data) ->
if data.status == 'ok'
that.fullReset()
showAlert 'success', "successfully logged in '#{data.login}'"
location.reload()
else
switch data.text
when 'login'
showAlert 'warning', 'Invalid username and/or password.'
login.addClass 'is-invalid'
password.addClass 'is-invalid'
when 'db' or 'hash'
showAlert 'danger', 'Internal failure, try again later.'
else
showAlert 'danger', 'Unknown failure. Can you reproduce this?'
# registration form
$('form[name="register"]').submit (event) ->
that = @
# inhibit normal form submission
event.preventDefault()
# gather form data
login = $('input[name="login"]', @)
password = $('input[name="password"]', @)
confirm = $('input[name="confirm"]', @)
# check form data
if password.val() != confirm.val()
confirm.addClass 'is-invalid'
confirm.focus()
else
# transmit form data
$.post '/register',
login: login.val()
password: password.val()
.done (data) ->
if data.status == 'ok'
that.fullReset()
showAlert 'success', "successfully registered '#{data.login}'"
else
switch data.text
when 'invalid'
showAlert 'warning', 'Invalid user input. Please provide username AND password.'
login.addClass 'is-invalid'
password.addClass 'is-invalid'
login.focus()
when 'hash'
showAlert 'danger', 'Internal failure, try again later.'
when 'db'
showAlert 'danger', 'Internal failure or user name already taken.'
login.addClass 'is-invalid'
login.focus()
else
showAlert 'danger', 'Unknown failure. Can you reproduce this?'

View file

@ -1,19 +0,0 @@
//
// Bootstrap and its default variables
//
@import "~bootstrap/scss/bootstrap";
html, body {
height: 100%;
}
body {
margin: 0;
background-color: #aaa;
// background-image: url(//gameranx.com/wp-content/uploads/2016/02/Final-Fantasy-XV-4K-Wallpaper.jpg);
background-position-x: center;
background-position-y: center;
background-size: cover;
}

View file

@ -1,11 +0,0 @@
# libs
window.$ = require('jquery')
# import bootstrap
require './style/custom.scss'
require 'bootstrap/js/dist/alert'
require 'bootstrap/js/dist/collapse'
# on load
$ ->
return

View file

@ -1,6 +0,0 @@
doctype html
html
head
title Crafty Things
script(src='/game.bundle.js')
body

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