├── .gitignore ├── LICENSE-MIT ├── Makefile ├── README.md ├── app.js ├── data └── mws.json ├── doc └── design.md ├── package.json ├── public ├── favicon.ico ├── index.html ├── media │ └── beep.wav ├── src │ ├── app.js │ ├── cards.js │ ├── components │ │ ├── chat.js │ │ ├── checkbox.js │ │ ├── cols.js │ │ ├── game.js │ │ ├── grid.js │ │ ├── lobby.js │ │ └── settings.js │ ├── data.js │ ├── init.js │ └── router.js └── style.css ├── run.js └── src ├── _.js ├── bot.js ├── data.js ├── game.js ├── hash.js ├── human.js ├── make ├── cards.js ├── custom.js ├── index.js └── score.js ├── pool.js ├── room.js ├── router.js ├── sock.js └── util.js /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | .DS_Store 3 | node_modules 4 | 5 | data 6 | public/out 7 | public/lib 8 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) James Campos 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: install clean cards score js 2 | 3 | node := ${CURDIR}/node_modules 4 | 5 | install: 6 | npm install 7 | mkdir -p public/lib 8 | ln -sf ${node}/normalize.css/normalize.css public/lib 9 | ln -sf ${node}/react/dist/react.js public/lib 10 | ln -sf ${node}/engine.io-client/engine.io.js public/lib 11 | ln -sf ${node}/traceur/bin/traceur.js public/lib 12 | ln -sf ${node}/traceur/bin/traceur-runtime.js public/lib 13 | ln -sf ${node}/ee/ee.js public/lib 14 | ln -sf ${node}/utils/utils.js public/lib 15 | 16 | clean: 17 | rm -f data/AllSets.json 18 | 19 | cards: data/AllSets.json 20 | node src/make cards 21 | 22 | custom: 23 | node src/make custom 24 | 25 | data/AllSets.json: 26 | curl -so data/AllSets.json https://mtgjson.com/json/AllSets.json 27 | 28 | score: 29 | -node src/make score #ignore errors 30 | 31 | js: 32 | node_modules/.bin/traceur --out public/lib/app.js public/src/init.js 33 | 34 | run: js 35 | node run 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # draft 2 | 3 | unaffiliated with wizards of the coast 4 | 5 | # run 6 | 7 | - [node.js](http://nodejs.org/) 8 | 9 | - `make` 10 | 11 | - `node app.js` 12 | 13 | - 14 | 15 | # updating 16 | 17 | generally you can update with `git pull`; if that doesn't work, 18 | rerun `make`; if that still doesn't work, please file an issue 19 | 20 | # etc 21 | 22 | written in [es6], transpiled with [traceur], using [react] on the client 23 | 24 | for the editor component, see 25 | 26 | [es6]: https://github.com/lukehoban/es6features 27 | [traceur]: https://github.com/google/traceur-compiler 28 | [react]: https://github.com/facebook/react 29 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | var PORT = 1337 2 | var http = require('http') 3 | var eio = require('engine.io') 4 | var send = require('send') 5 | var traceur = require('traceur') 6 | 7 | traceur.require.makeDefault(function(path) { 8 | return path.indexOf('node_modules') === -1 9 | }) 10 | var router = require('./src/router') 11 | 12 | var server = http.createServer(function(req, res) { 13 | send(req, req.url, { root: 'public' }).pipe(res) 14 | }).listen(PORT) 15 | var eioServer = eio(server).on('connection', router) 16 | 17 | console.log(new Date) 18 | -------------------------------------------------------------------------------- /data/mws.json: -------------------------------------------------------------------------------- 1 | { 2 | "LEA":"A", 3 | "LEB":"B", 4 | "2ED":"U", 5 | "3ED":"R", 6 | "4ED":"4E", 7 | "5ED":"5E", 8 | "6ED":"6E", 9 | "7ED":"7E", 10 | "8ED":"8E", 11 | "9ED":"9E", 12 | 13 | "ARN":"AN", 14 | "ATQ":"AQ", 15 | "LEG":"LG", 16 | "DRK":"DK", 17 | "FEM":"FE", 18 | "HML":"HL", 19 | "ICE":"IA", 20 | "ALL":"AL", 21 | "CSP":"CS", 22 | "MIR":"MI", 23 | "VIS":"VI", 24 | "WTH":"WL", 25 | "TMP":"TE", 26 | "STH":"SH", 27 | "EXO":"EX", 28 | "USG":"US", 29 | "ULG":"UL", 30 | "UDS":"UD", 31 | "MMQ":"MM", 32 | "NMS":"NE", 33 | "PCY":"PY", 34 | "INV":"IN", 35 | "PLS":"PS", 36 | "APC":"AP", 37 | "ODY":"OD", 38 | "TOR":"TO", 39 | "JUD":"JU", 40 | "ONS":"ON", 41 | "LGN":"LE", 42 | "SCG":"SC", 43 | "MRD":"MR", 44 | "DST":"DS", 45 | "5DN":"FD", 46 | "GPT":"GP", 47 | "CON":"CFX", 48 | 49 | "POR":"PT", 50 | "PO2":"P2", 51 | "PTK":"P3", 52 | "S99":"ST", 53 | "UGL":"UG" 54 | } 55 | -------------------------------------------------------------------------------- /doc/design.md: -------------------------------------------------------------------------------- 1 | # design 2 | 3 | ## server 4 | 5 | _: generic js utilities 6 | 7 | bot: class 8 | 9 | data: wrapper around the data folder 10 | 11 | game: class 12 | 13 | hash: create cockatrice/mws deck hashes 14 | 15 | human: class 16 | 17 | pool: generates the cardpool for a specific game 18 | 19 | room: base class for game room. used as the lobby 20 | 21 | router: accepts new sockets, routes players into rooms (lobby or game) 22 | 23 | sock: socket wrapper 24 | 25 | util: validates game options, decklist for hashing 26 | 27 | ## client 28 | 29 | components: ui 30 | 31 | app: utility, default options 32 | 33 | cards: logic 34 | 35 | data: set codes and names 36 | 37 | init: traceur cannot into circular dependencies 38 | 39 | router: does routing 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "draft", 3 | "version": "0.2.0", 4 | "description": "game client/server", 5 | "author": "James Campos ", 6 | "repository": "aeosynth/draft", 7 | "dependencies": { 8 | "ee": "git://github.com/aeosynth/ee", 9 | "engine.io": "^1.4.1", 10 | "engine.io-client": "^1.4.1", 11 | "node-fetch": "^1.0.3", 12 | "send": "^0.11.1", 13 | "traceur": "0.0.65", 14 | "utils": "git://github.com/aeosynth/utils" 15 | }, 16 | "devDependencies": { 17 | "normalize.css": "^3.0.1", 18 | "react": "^0.11.2" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aeosynth/draft/5f9936a7449351ec7450824b9d36fa12f9f767dd/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | draft 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /public/media/beep.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aeosynth/draft/5f9936a7449351ec7450824b9d36fa12f9f767dd/public/media/beep.wav -------------------------------------------------------------------------------- /public/src/app.js: -------------------------------------------------------------------------------- 1 | import _ from '../lib/utils' 2 | import EventEmitter from '../lib/ee' 3 | 4 | function message(msg) { 5 | let args = JSON.parse(msg) 6 | App.emit(...args) 7 | } 8 | 9 | let App = { 10 | __proto__: new EventEmitter, 11 | 12 | state: { 13 | id: null, 14 | name: 'newfriend', 15 | 16 | seats: 8, 17 | type: 'draft', 18 | sets: [ 19 | 'BFZ', 20 | 'BFZ', 21 | 'BFZ', 22 | 'BFZ', 23 | 'BFZ', 24 | 'BFZ' 25 | ], 26 | list: '', 27 | cards: 15, 28 | packs: 3, 29 | 30 | bots: true, 31 | timer: true, 32 | 33 | beep: false, 34 | chat: true, 35 | cols: false, 36 | filename: 'filename', 37 | filetype: 'txt', 38 | side: false, 39 | sort: 'color', 40 | }, 41 | 42 | init(router) { 43 | App.on('set', App.set) 44 | App.on('error', App.error) 45 | App.on('route', App.route) 46 | 47 | App.restore() 48 | App.connect() 49 | router(App) 50 | }, 51 | restore() { 52 | for (let key in this.state) { 53 | let val = localStorage[key] 54 | if (val) { 55 | try { 56 | this.state[key] = JSON.parse(val) 57 | } catch(e) { 58 | delete localStorage[key] 59 | } 60 | } 61 | } 62 | 63 | if (!this.state.id) { 64 | this.state.id = _.uid() 65 | localStorage.id = JSON.stringify(this.state.id) 66 | } 67 | }, 68 | connect() { 69 | let {id, name} = App.state 70 | let options = { 71 | query: { id, name } 72 | } 73 | let ws = this.ws = eio(location.host, options) 74 | ws.on('open' , ()=> console.log('open')) 75 | ws.on('close', ()=> console.log('close')) 76 | ws.on('message', message) 77 | }, 78 | send(...args) { 79 | let msg = JSON.stringify(args) 80 | this.ws.send(msg) 81 | }, 82 | error(err) { 83 | App.err = err 84 | App.route('') 85 | }, 86 | route(path) { 87 | if (path === location.hash.slice(1)) 88 | App.update() 89 | else 90 | location.hash = path 91 | }, 92 | save(key, val) { 93 | this.state[key] = val 94 | localStorage[key] = JSON.stringify(val) 95 | App.update() 96 | }, 97 | set(state) { 98 | Object.assign(App.state, state) 99 | App.update() 100 | }, 101 | update() { 102 | React.renderComponent(App.component, document.body) 103 | }, 104 | _emit(...args) { 105 | return App.emit.bind(App, ...args) 106 | }, 107 | _save(key, val) { 108 | return App.save.bind(App, key, val) 109 | }, 110 | link(key, index) { 111 | let hasIndex = index !== void 0 112 | 113 | let value = App.state[key] 114 | if (hasIndex) 115 | value = value[index] 116 | 117 | function requestChange(val) { 118 | if (hasIndex) { 119 | let tmp = App.state[key] 120 | tmp[index] = val 121 | val = tmp 122 | } 123 | App.save(key, val) 124 | } 125 | 126 | return { requestChange, value } 127 | }, 128 | } 129 | 130 | export default App 131 | -------------------------------------------------------------------------------- /public/src/cards.js: -------------------------------------------------------------------------------- 1 | import _ from '../lib/utils' 2 | import App from './app' 3 | 4 | let Cards = { 5 | Plains: 401994, 6 | Island: 401927, 7 | Swamp: 402059, 8 | Mountain: 401961, 9 | Forest: 401886 10 | } 11 | 12 | export let BASICS = Object.keys(Cards) 13 | 14 | for (let name in Cards) 15 | Cards[name] = {name, 16 | cmc: 0, 17 | code: 'BFZ', 18 | color: 'colorless', 19 | rarity: 'basic', 20 | type: 'Land', 21 | url: 'http://gatherer.wizards.com/Handlers/Image.ashx?type=card&' + 22 | `multiverseid=${Cards[name]}` 23 | } 24 | 25 | let rawPack, clicked 26 | export let Zones = { 27 | pack: {}, 28 | main: {}, 29 | side: {}, 30 | junk: {} 31 | } 32 | 33 | function hash() { 34 | let {main, side} = Zones 35 | App.send('hash', { main, side }) 36 | } 37 | 38 | let events = { 39 | side() { 40 | let dst = Zones[App.state.side ? 'side' : 'main'] 41 | 42 | let srcName = App.state.side ? 'main' : 'side' 43 | let src = Zones[srcName] 44 | 45 | _.add(src, dst) 46 | Zones[srcName] = {} 47 | }, 48 | add(cardName) { 49 | let zone = Zones[App.state.side ? 'side' : 'main'] 50 | zone[cardName] || (zone[cardName] = 0) 51 | zone[cardName]++ 52 | App.update() 53 | }, 54 | click(zoneName, cardName, e) { 55 | if (zoneName === 'pack') 56 | return clickPack(cardName) 57 | 58 | let src = Zones[zoneName] 59 | let dst = Zones[e.shiftKey 60 | ? zoneName === 'junk' ? 'main' : 'junk' 61 | : zoneName === 'side' ? 'main' : 'side'] 62 | 63 | dst[cardName] || (dst[cardName] = 0) 64 | 65 | src[cardName]-- 66 | dst[cardName]++ 67 | 68 | if (!src[cardName]) 69 | delete src[cardName] 70 | 71 | App.update() 72 | }, 73 | copy(ref) { 74 | let node = ref.getDOMNode() 75 | node.value = filetypes.txt() 76 | node.select() 77 | hash() 78 | }, 79 | download() { 80 | let {filename, filetype} = App.state 81 | let data = filetypes[filetype]() 82 | _.download(data, filename + '.' + filetype) 83 | hash() 84 | }, 85 | start() { 86 | let {bots, timer} = App.state 87 | let options = [bots, timer] 88 | App.send('start', options) 89 | }, 90 | pack(cards) { 91 | rawPack = cards 92 | let pack = Zones.pack = {} 93 | 94 | for (let card of cards) { 95 | let {name} = card 96 | Cards[name] = card 97 | pack[name] = 1 98 | } 99 | App.update() 100 | if (App.state.beep) 101 | document.getElementById('beep').play() 102 | }, 103 | create() { 104 | let {type, seats} = App.state 105 | seats = Number(seats) 106 | let options = { type, seats } 107 | 108 | if (/cube/.test(type)) 109 | options.cube = cube() 110 | else { 111 | let {sets} = App.state 112 | if (type === 'draft') 113 | sets = sets.slice(0, 3) 114 | options.sets = sets 115 | } 116 | 117 | App.send('create', options) 118 | }, 119 | pool(cards) { 120 | ['main', 'side', 'junk'].forEach(zoneName => Zones[zoneName] = {}) 121 | 122 | let zone = Zones[App.state.side 123 | ? 'side' 124 | : 'main'] 125 | 126 | for (let card of cards) { 127 | let {name} = card 128 | Cards[name] = card 129 | 130 | zone[name] || (zone[name] = 0) 131 | zone[name]++ 132 | } 133 | App.update() 134 | }, 135 | land(zoneName, cardName, e) { 136 | let n = Number(e.target.value) 137 | if (n) 138 | Zones[zoneName][cardName] = n 139 | else 140 | delete Zones[zoneName][cardName] 141 | App.update() 142 | }, 143 | } 144 | 145 | for (let event in events) 146 | App.on(event, events[event]) 147 | 148 | function codify(zone) { 149 | let arr = [] 150 | for (let name in zone) 151 | arr.push(` `) 152 | return arr.join('\n') 153 | } 154 | 155 | let filetypes = { 156 | cod() { 157 | return `\ 158 | 159 | 160 | ${App.state.filename} 161 | 162 | ${codify(Zones.main)} 163 | 164 | 165 | ${codify(Zones.side)} 166 | 167 | ` 168 | }, 169 | mwdeck() { 170 | let arr = [] 171 | ;['main', 'side'].forEach(zoneName => { 172 | let prefix = zoneName === 'side' ? 'SB: ' : '' 173 | let zone = Zones[zoneName] 174 | for (let name in zone) { 175 | let {code} = Cards[name] 176 | let count = zone[name] 177 | name = name.replace(' // ', '/') 178 | arr.push(`${prefix}${count} [${code}] ${name}`) 179 | } 180 | }) 181 | return arr.join('\n') 182 | }, 183 | json() { 184 | let {main, side} = Zones 185 | return JSON.stringify({ main, side }, null, 2) 186 | }, 187 | txt() { 188 | let arr = [] 189 | ;['main', 'side'].forEach(zoneName => { 190 | if (zoneName === 'side') 191 | arr.push('Sideboard') 192 | let zone = Zones[zoneName] 193 | for (let name in zone) { 194 | let count = zone[name] 195 | arr.push(count + ' ' + name) 196 | } 197 | }) 198 | return arr.join('\n') 199 | } 200 | } 201 | 202 | function cube() { 203 | let {list, cards, packs} = App.state 204 | cards = Number(cards) 205 | packs = Number(packs) 206 | 207 | list = list 208 | .split('\n') 209 | .map(x => x 210 | .trim() 211 | .replace(/^\d+.\s*/, '') 212 | .replace(/\s*\/+\s*/g, ' // ') 213 | .toLowerCase()) 214 | .filter(x => x) 215 | .join('\n') 216 | 217 | return { list, cards, packs } 218 | } 219 | 220 | function clickPack(cardName) { 221 | if (clicked !== cardName) 222 | return clicked = cardName 223 | 224 | let index = rawPack.findIndex(x => x.name === cardName) 225 | clicked = null 226 | Zones.pack = {} 227 | App.update() 228 | App.send('pick', index) 229 | } 230 | 231 | function Key(groups, sort) { 232 | let keys = Object.keys(groups) 233 | 234 | switch(sort) { 235 | case 'cmc': 236 | let arr = [] 237 | for (let key in groups) 238 | if (parseInt(key) > 6) { 239 | ;[].push.apply(arr, groups[key]) 240 | delete groups[key] 241 | } 242 | 243 | if (arr.length) { 244 | groups['6'] || (groups['6'] = []) 245 | ;[].push.apply(groups['6'], arr) 246 | } 247 | return groups 248 | 249 | case 'color': 250 | keys = 251 | ['colorless', 'white', 'blue', 'black', 'red', 'green', 'multicolor'] 252 | .filter(x => keys.indexOf(x) > -1) 253 | break 254 | case 'rarity': 255 | keys = 256 | ['basic', 'common', 'uncommon', 'rare', 'mythic', 'special'] 257 | .filter(x => keys.indexOf(x) > -1) 258 | break 259 | case 'type': 260 | keys = keys.sort() 261 | break 262 | } 263 | 264 | let o = {} 265 | for (let key of keys) 266 | o[key] = groups[key] 267 | return o 268 | } 269 | 270 | export function getZone(zoneName) { 271 | let zone = Zones[zoneName] 272 | 273 | let cards = [] 274 | for (let cardName in zone) 275 | for (let i = 0; i < zone[cardName]; i++) 276 | cards.push(Cards[cardName]) 277 | 278 | let {sort} = App.state 279 | let groups = _.group(cards, sort) 280 | for (let key in groups) 281 | _.sort(groups[key], 'color', 'cmc', 'name') 282 | 283 | groups = Key(groups, sort) 284 | 285 | return groups 286 | } 287 | -------------------------------------------------------------------------------- /public/src/components/chat.js: -------------------------------------------------------------------------------- 1 | import _ from '../../lib/utils' 2 | import App from '../app' 3 | let d = React.DOM 4 | 5 | export default React.createClass({ 6 | getInitialState() { 7 | return { 8 | messages: [] 9 | } 10 | }, 11 | componentDidMount() { 12 | this.refs.entry.getDOMNode().focus() 13 | App.on('hear', this.hear) 14 | App.on('chat', messages => this.setState({ messages })) 15 | }, 16 | componentWillUnmount() { 17 | App.off('hear') 18 | App.off('chat') 19 | }, 20 | render() { 21 | // must be mounted to receive messages 22 | let {hidden} = this.props 23 | return d.div({ hidden, id: 'chat' }, 24 | d.div({ id: 'messages', ref: 'messages'}, 25 | this.state.messages.map(this.Message)), 26 | this.Entry()) 27 | }, 28 | 29 | hear(msg) { 30 | this.state.messages.push(msg) 31 | this.forceUpdate(this.scrollChat) 32 | }, 33 | scrollChat() { 34 | let el = this.refs.messages.getDOMNode() 35 | el.scrollTop = el.scrollHeight 36 | }, 37 | Message(msg) { 38 | if (!msg) 39 | return 40 | 41 | let {time, name, text} = msg 42 | let date = new Date(time) 43 | let hours = _.pad(2, '0', date.getHours()) 44 | let minutes = _.pad(2, '0', date.getMinutes()) 45 | time = `${hours}:${minutes}` 46 | 47 | return d.div({}, 48 | d.time({}, time), 49 | ' ', 50 | d.span({ className: 'name' }, name), 51 | ' ', 52 | text) 53 | }, 54 | 55 | Entry() { 56 | return d.input({ 57 | ref: 'entry', 58 | onKeyDown: this.key, 59 | placeholder: '/nick name' 60 | }) 61 | }, 62 | 63 | key(e) { 64 | if (e.key !== 'Enter') 65 | return 66 | 67 | let el = e.target 68 | let text = el.value.trim() 69 | el.value = '' 70 | 71 | if (!text) 72 | return 73 | 74 | if (text[0] === '/') 75 | this.command(text.slice(1)) 76 | else 77 | App.send('say', text) 78 | }, 79 | 80 | command(raw) { 81 | let [, command, arg] = raw.match(/(\w*)\s*(.*)/) 82 | arg = arg.trim() 83 | let text 84 | 85 | switch(command) { 86 | case 'name': 87 | case 'nick': 88 | let name = arg.slice(0, 15) 89 | 90 | if (!name) { 91 | text = 'enter a name' 92 | break 93 | } 94 | 95 | text = `hello, ${name}` 96 | App.save('name', name) 97 | App.send('name', name) 98 | break 99 | default: 100 | text = `unsupported command: ${command}` 101 | } 102 | 103 | this.state.messages.push({ text, 104 | time: Date.now(), 105 | name: '' 106 | }) 107 | this.forceUpdate(this.scrollChat) 108 | } 109 | }) 110 | -------------------------------------------------------------------------------- /public/src/components/checkbox.js: -------------------------------------------------------------------------------- 1 | import App from '../app' 2 | let d = React.DOM 3 | 4 | export function LBox(key, text) { 5 | return d.div({}, 6 | d.label({}, 7 | d.input({ 8 | checkedLink: App.link(key), 9 | type: 'checkbox' 10 | }), 11 | ' ' + text)) 12 | } 13 | 14 | export function RBox(key, text) { 15 | return d.div({}, 16 | d.label({}, 17 | text + ' ', 18 | d.input({ 19 | checkedLink: App.link(key), 20 | type: 'checkbox' 21 | }))) 22 | } 23 | -------------------------------------------------------------------------------- /public/src/components/cols.js: -------------------------------------------------------------------------------- 1 | import App from '../app' 2 | import {getZone} from '../cards' 3 | let d = React.DOM 4 | 5 | export default React.createClass({ 6 | getInitialState() { 7 | return { 8 | className: 'right' 9 | } 10 | }, 11 | render() { 12 | let zones = this.props.zones.map(this.zone) 13 | let img = this.state.url && d.img({ 14 | className: this.state.className, 15 | id: 'img', 16 | onMouseEnter: this.enter.bind(this, this.state.url), 17 | src: this.state.url 18 | }) 19 | return d.div({}, zones, img) 20 | }, 21 | 22 | enter(url, e) { 23 | let {offsetLeft} = e.target 24 | let {clientWidth} = document.documentElement 25 | 26 | let imgWidth = 240 27 | let colWidth = 180 28 | 29 | let className = offsetLeft + colWidth > clientWidth - imgWidth 30 | ? 'left' 31 | : 'right' 32 | 33 | this.setState({ url, className }) 34 | }, 35 | zone(zoneName) { 36 | let zone = getZone(zoneName) 37 | 38 | let sum = 0 39 | let cols = [] 40 | for (let key in zone) { 41 | let items = zone[key].map(card => 42 | d.div({ 43 | onClick: App._emit('click', zoneName, card.name), 44 | onMouseOver: this.enter.bind(this, card.url) 45 | }, d.img({ src: card.url, alt: card.name })) 46 | ) 47 | 48 | sum += items.length 49 | cols.push(d.div({ className: 'col' }, 50 | d.div({}, `${items.length} - ${key}`), 51 | items)) 52 | } 53 | 54 | return d.div({ className: 'zone' }, 55 | d.h1({}, `${zoneName} ${sum}`), 56 | cols) 57 | } 58 | }) 59 | -------------------------------------------------------------------------------- /public/src/components/game.js: -------------------------------------------------------------------------------- 1 | import App from '../app' 2 | import {Zones} from '../cards' 3 | 4 | import Chat from './chat' 5 | import Cols from './cols' 6 | import Grid from './grid' 7 | import Settings from './settings' 8 | import {LBox} from './checkbox' 9 | let d = React.DOM 10 | 11 | export default React.createClass({ 12 | componentWillMount() { 13 | App.state.players = [] 14 | App.send('join', this.props.id) 15 | }, 16 | componentDidMount() { 17 | this.timer = window.setInterval(decrement, 1e3) 18 | }, 19 | componentWillUnmount() { 20 | window.clearInterval(this.timer) 21 | }, 22 | componentWillReceiveProps({id}) { 23 | if (this.props.id === id) 24 | return 25 | 26 | App.send('join', id) 27 | }, 28 | render() { 29 | let {chat} = App.state 30 | if (chat) 31 | let className = 'chat' 32 | 33 | return d.div({ className }, 34 | Chat({ hidden: !chat }), 35 | d.audio({ id: 'beep', src: '/media/beep.wav' }), 36 | Settings(), 37 | this.Start(), 38 | d.div({}, App.state.title), 39 | this.Players(), 40 | this.Cards()) 41 | }, 42 | 43 | Cards() { 44 | if (Object.keys(Zones.pack).length) 45 | let pack = Grid({ zones: ['pack'] }) 46 | let component = App.state.cols ? Cols : Grid 47 | let pool = component({ zones: ['main', 'side', 'junk'] }) 48 | return [pack, pool] 49 | }, 50 | Start() { 51 | if (App.state.round || !App.state.isHost) 52 | return 53 | 54 | return d.div({}, 55 | d.div({}, 56 | d.button({ onClick: App._emit('start') }, 'start')), 57 | LBox('bots', 'bots'), 58 | LBox('timer', 'timer')) 59 | }, 60 | Players() { 61 | let rows = App.state.players.map(row) 62 | return d.table({ id: 'players' }, 63 | d.tr({}, 64 | d.th({}, '#'), 65 | d.th({}, 'name'), 66 | d.th({}, 'packs'), 67 | d.th({}, 'time'), 68 | d.th({}, 'cock'), 69 | d.th({}, 'mws')), 70 | rows) 71 | } 72 | }) 73 | 74 | function row(p, i) { 75 | let {players, self} = App.state 76 | let {length} = players 77 | 78 | if (length % 2 === 0) 79 | let opp = (self + length/2) % length 80 | 81 | let className 82 | = i === self ? 'self' 83 | : i === opp ? 'opp' 84 | : null 85 | 86 | return d.tr({ className }, 87 | d.td({}, i + 1), 88 | d.td({}, p.name), 89 | d.td({}, p.packs), 90 | d.td({}, p.time), 91 | d.td({}, p.hash && p.hash.cock), 92 | d.td({}, p.hash && p.hash.mws)) 93 | } 94 | 95 | function decrement() { 96 | for (let p of App.state.players) 97 | if (p.time) 98 | p.time-- 99 | App.update() 100 | } 101 | -------------------------------------------------------------------------------- /public/src/components/grid.js: -------------------------------------------------------------------------------- 1 | import _ from '../../lib/utils' 2 | import App from '../app' 3 | import {getZone} from '../cards' 4 | let d = React.DOM 5 | 6 | export default React.createClass({ 7 | render() { 8 | let zones = this.props.zones.map(zone) 9 | return d.div({}, zones) 10 | } 11 | }) 12 | 13 | function zone(zoneName) { 14 | let zone = getZone(zoneName) 15 | let values = _.values(zone) 16 | let cards = _.flat(values) 17 | 18 | let items = cards.map(card => 19 | d.img({ 20 | onClick: App._emit('click', zoneName, card.name), 21 | src: card.url, 22 | alt: card.name 23 | })) 24 | 25 | return d.div({ className: 'zone' }, 26 | d.h1({}, `${zoneName} ${cards.length}`), 27 | items) 28 | } 29 | -------------------------------------------------------------------------------- /public/src/components/lobby.js: -------------------------------------------------------------------------------- 1 | import _ from '../../lib/utils' 2 | import App from '../app' 3 | import data from '../data' 4 | import Chat from './chat' 5 | let d = React.DOM 6 | 7 | export default React.createClass({ 8 | componentDidMount() { 9 | App.send('join', 'lobby') 10 | }, 11 | render() { 12 | return d.div({}, 13 | Chat(), 14 | d.h1({}, 'drafts.in'), 15 | d.p({ className: 'error' }, App.err), 16 | Create(), 17 | d.footer({}, 18 | d.div({}, 19 | d.a({ className: 'icon ion-social-github', href: 'https://github.com/aeosynth/draft' }), 20 | d.a({ className: 'icon ion-social-twitter', href: 'https://twitter.com/aeosynth' }), 21 | d.a({ className: 'icon ion-android-mail', href: 'mailto:james.r.campos@gmail.com' })), 22 | d.div({}, 23 | d.small({}, 'unaffiliated with wizards of the coast')))) 24 | } 25 | }) 26 | 27 | function Sets(selectedSet, index) { 28 | let groups = [] 29 | for (let label in data) { 30 | let sets = data[label] 31 | let options = [] 32 | for (let name in sets) { 33 | let code = sets[name] 34 | options.push(d.option({ value: code }, name)) 35 | } 36 | groups.push(d.optgroup({ label }, options)) 37 | } 38 | return d.select({ 39 | valueLink: App.link('sets', index) 40 | }, groups) 41 | } 42 | 43 | function content() { 44 | let sets = App.state.sets.map(Sets) 45 | let setsTop = d.div({}, sets.slice(0, 3)) 46 | let setsBot = d.div({}, sets.slice(3)) 47 | 48 | let cube = [ 49 | d.div({}, 'one card per line'), 50 | d.textarea({ 51 | placeholder: 'cube list', 52 | valueLink: App.link('list') 53 | }) 54 | ] 55 | 56 | let cards = _.seq(15, 8).map(x => d.option({}, x)) 57 | let packs = _.seq( 7, 3).map(x => d.option({}, x)) 58 | let cubeDraft = d.div({}, 59 | d.select({ valueLink: App.link('cards') }, cards), 60 | ' cards ', 61 | d.select({ valueLink: App.link('packs') }, packs), 62 | ' packs') 63 | 64 | switch(App.state.type) { 65 | case 'draft' : return setsTop 66 | case 'sealed': return [setsTop, setsBot] 67 | case 'cube draft' : return [cube, cubeDraft] 68 | case 'cube sealed': return cube 69 | case 'editor': return d.a({ href: 'http://editor.draft.wtf' }, 'editor') 70 | } 71 | } 72 | 73 | function Create() { 74 | let seats = _.seq(8, 2).map(x => 75 | d.option({}, x)) 76 | 77 | let types = ['draft', 'sealed', 'cube draft', 'cube sealed', 'editor'] 78 | .map(type => 79 | d.button({ 80 | disabled: type === App.state.type, 81 | onClick: App._save('type', type) 82 | }, type)) 83 | 84 | return d.div({}, 85 | d.div({}, 86 | d.button({ onClick: App._emit('create') }, 'create'), 87 | ' room for ', 88 | d.select({ valueLink: App.link('seats') }, seats)), 89 | d.div({}, types), 90 | content()) 91 | } 92 | -------------------------------------------------------------------------------- /public/src/components/settings.js: -------------------------------------------------------------------------------- 1 | import App from '../app' 2 | import {BASICS, Zones} from '../cards' 3 | import {RBox} from './checkbox' 4 | let d = React.DOM 5 | 6 | function Lands() { 7 | let colors = ['White', 'Blue', 'Black', 'Red', 'Green'] 8 | let symbols = colors.map(x => 9 | d.td({}, 10 | d.img({ src: `http://www.wizards.com/Magic/redesign/${x}_Mana.png` }))) 11 | 12 | let [main, side] = ['main', 'side'].map(zoneName => { 13 | let inputs = BASICS.map(cardName => 14 | d.td({}, 15 | d.input({ 16 | min: 0, 17 | onChange: App._emit('land', zoneName, cardName), 18 | type: 'number', 19 | value: Zones[zoneName][cardName] || 0 20 | }))) 21 | 22 | return d.tr({}, 23 | d.td({}, zoneName), 24 | inputs) 25 | }) 26 | 27 | return d.table({}, 28 | d.tr({}, 29 | d.td(), 30 | symbols), 31 | main, 32 | side) 33 | } 34 | 35 | function Sort() { 36 | return d.div({}, 37 | ['cmc', 'color', 'type', 'rarity'].map(sort => 38 | d.button({ 39 | disabled: sort === App.state.sort, 40 | onClick: App._save('sort', sort) 41 | }, sort))) 42 | } 43 | 44 | function Download() { 45 | let filetypes = ['cod', 'json', 'mwdeck', 'txt'].map(filetype => 46 | d.option({}, filetype)) 47 | let select = d.select({ valueLink: App.link('filetype') }, filetypes) 48 | 49 | return d.div({}, 50 | d.button({ onClick: App._emit('download') }, 'download'), 51 | d.input({ placeholder: 'filename', valueLink: App.link('filename') }), 52 | select) 53 | } 54 | 55 | export default React.createClass({ 56 | render() { 57 | return d.div({ id: 'settings' }, 58 | RBox('chat', 'chat'), 59 | Lands(), 60 | Download(), 61 | this.Copy(), 62 | this.Side(), 63 | RBox('beep', 'beep for new packs'), 64 | RBox('cols', 'column view'), 65 | Sort()) 66 | }, 67 | SideCB(e) { 68 | let side = e.target.checked 69 | App.save('side', side) 70 | App.emit('side') 71 | }, 72 | Side() { 73 | return d.div({}, 74 | d.label({}, 75 | 'add cards to side ', 76 | d.input({ 77 | checked: App.state.side, 78 | onChange: this.SideCB, 79 | type: 'checkbox' 80 | }))) 81 | }, 82 | Copy() { 83 | return d.div({}, 84 | d.button({ 85 | onClick: App._emit('copy', this.refs.decklist) 86 | }, 'copy'), 87 | d.textarea({ 88 | placeholder: 'decklist', 89 | ref: 'decklist', 90 | readOnly: true 91 | })) 92 | } 93 | }) 94 | -------------------------------------------------------------------------------- /public/src/data.js: -------------------------------------------------------------------------------- 1 | export default { 2 | expansion: { 3 | "Oath of the Gatewatch": "OGW", 4 | "Battle for Zendikar": "BFZ", 5 | "Dragons of Tarkir": "DTK", 6 | "Fate Reforged": "FRF", 7 | "Khans of Tarkir": "KTK", 8 | "Journey into Nyx": "JOU", 9 | "Born of the Gods": "BNG", 10 | "Theros": "THS", 11 | "Dragon's Maze": "DGM", 12 | "Gatecrash": "GTC", 13 | "Return to Ravnica": "RTR", 14 | "Avacyn Restored": "AVR", 15 | "Dark Ascension": "DKA", 16 | "Innistrad": "ISD", 17 | "New Phyrexia": "NPH", 18 | "Mirrodin Besieged": "MBS", 19 | "Scars of Mirrodin": "SOM", 20 | "Rise of the Eldrazi": "ROE", 21 | "Worldwake": "WWK", 22 | "Zendikar": "ZEN", 23 | "Alara Reborn": "ARB", 24 | "Conflux": "CON", 25 | "Shards of Alara": "ALA", 26 | "Eventide": "EVE", 27 | "Shadowmoor": "SHM", 28 | "Morningtide": "MOR", 29 | "Lorwyn": "LRW", 30 | "Future Sight": "FUT", 31 | "Planar Chaos": "PLC", 32 | "Time Spiral": "TSP", 33 | "Coldsnap": "CSP", 34 | "Dissension": "DIS", 35 | "Guildpact": "GPT", 36 | "Ravnica: City of Guilds": "RAV", 37 | "Saviors of Kamigawa": "SOK", 38 | "Betrayers of Kamigawa": "BOK", 39 | "Champions of Kamigawa": "CHK", 40 | "Fifth Dawn": "5DN", 41 | "Darksteel": "DST", 42 | "Mirrodin": "MRD", 43 | "Scourge": "SCG", 44 | "Legions": "LGN", 45 | "Onslaught": "ONS", 46 | "Judgment": "JUD", 47 | "Torment": "TOR", 48 | "Odyssey": "ODY", 49 | "Apocalypse": "APC", 50 | "Planeshift": "PLS", 51 | "Invasion": "INV", 52 | "Prophecy": "PCY", 53 | "Nemesis": "NMS", 54 | "Mercadian Masques": "MMQ", 55 | "Urza's Destiny": "UDS", 56 | "Urza's Legacy": "ULG", 57 | "Urza's Saga": "USG", 58 | "Exodus": "EXO", 59 | "Stronghold": "STH", 60 | "Tempest": "TMP", 61 | "Weatherlight": "WTH", 62 | "Visions": "VIS", 63 | "Mirage": "MIR", 64 | "Alliances": "ALL", 65 | "Homelands": "HML", 66 | "Ice Age": "ICE", 67 | "Fallen Empires": "FEM", 68 | "The Dark": "DRK", 69 | "Legends": "LEG", 70 | "Antiquities": "ATQ", 71 | "Arabian Nights": "ARN" 72 | }, 73 | core: { 74 | "Magic 2015 Core Set": "M15", 75 | "Magic 2014 Core Set": "M14", 76 | "Magic 2013": "M13", 77 | "Magic 2012": "M12", 78 | "Magic 2011": "M11", 79 | "Magic 2010": "M10", 80 | "Tenth Edition": "10E", 81 | "Ninth Edition": "9ED", 82 | "Eighth Edition": "8ED", 83 | "Seventh Edition": "7ED", 84 | "Classic Sixth Edition": "6ED", 85 | "Fifth Edition": "5ED", 86 | "Fourth Edition": "4ED", 87 | "Revised Edition": "3ED", 88 | "Unlimited Edition": "2ED", 89 | "Limited Edition Beta": "LEB", 90 | "Limited Edition Alpha": "LEA" 91 | }, 92 | other: { 93 | "Magic Origins": "ORI", 94 | "Modern Masters 2015": "MM2", 95 | "Tempest Remastered": "TPR", 96 | "Conspiracy": "CNS", 97 | "Vintage Masters": "VMA", 98 | "Modern Masters": "MMA", 99 | "Unhinged": "UNH", 100 | "Unglued": "UGL", 101 | "Starter 1999": "S99", 102 | "Portal Three Kingdoms": "PTK", 103 | "Portal Second Age": "PO2", 104 | "Portal": "POR" 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /public/src/init.js: -------------------------------------------------------------------------------- 1 | //traceur cannot into circular dependencies 2 | import App from './app' 3 | import router from './router' 4 | 5 | App.init(router) 6 | -------------------------------------------------------------------------------- /public/src/router.js: -------------------------------------------------------------------------------- 1 | import Lobby from './components/lobby' 2 | import Game from './components/game' 3 | let App 4 | 5 | export default function(_App) { 6 | App = _App 7 | route() 8 | window.addEventListener('hashchange', route) 9 | } 10 | 11 | function route() { 12 | let path = location.hash.slice(1) 13 | let [route, id] = path.split('/') 14 | let component 15 | 16 | switch(route) { 17 | case 'g': 18 | component = Game({ id }) 19 | break 20 | case '': 21 | component = Lobby() 22 | break 23 | default: 24 | return App.error(`not found: ${path}`) 25 | } 26 | 27 | App.component = component 28 | App.update() 29 | } 30 | -------------------------------------------------------------------------------- /public/style.css: -------------------------------------------------------------------------------- 1 | footer { 2 | position: fixed; 3 | bottom: 0; 4 | } 5 | 6 | label { 7 | cursor: pointer; 8 | } 9 | 10 | textarea { 11 | height: 1em; 12 | vertical-align: top; 13 | } 14 | 15 | time { 16 | color: #999; 17 | } 18 | 19 | .icon { 20 | font-size: 32px; 21 | margin: 16px; 22 | } 23 | 24 | .chat { 25 | margin-right: 351px; 26 | } 27 | 28 | .name { 29 | color: #393; 30 | } 31 | 32 | .colorless { background-color: rgba( 0, 0, 0, .1); } 33 | .red { background-color: rgba(255, 0, 0, .1); } 34 | .green { background-color: rgba( 0, 255, 0, .1); } 35 | .blue { background-color: rgba( 0, 0, 255, .1); } 36 | .multicolor { background-color: rgba(255, 255, 0, .1); } 37 | .black { 38 | background-color: black; 39 | color: white; 40 | } 41 | 42 | .error { 43 | color: red; 44 | } 45 | 46 | .zone { 47 | border-top: 1px solid black; 48 | clear: both; 49 | user-select: none; 50 | -moz-user-select: none; 51 | -webkit-user-select: none; 52 | } 53 | 54 | .zone img, #img { 55 | height: 340px; 56 | width: 240px; 57 | cursor: pointer; 58 | } 59 | 60 | .col { 61 | float: left; 62 | width: 180px; 63 | } 64 | 65 | .col img { 66 | height: 255px; 67 | width: 180px; 68 | margin-bottom: -235px; 69 | } 70 | 71 | .col div:last-child img { 72 | margin-bottom: 0; 73 | } 74 | 75 | .self { 76 | background-color: rgba(0, 255, 0, .1); 77 | } 78 | .opp { 79 | background-color: rgba(255, 0, 0, .1); 80 | } 81 | 82 | #img { 83 | position: fixed; 84 | bottom: 0; 85 | } 86 | #img.left { 87 | left: 0; 88 | } 89 | #img.right { 90 | right: 0; 91 | } 92 | 93 | #players th, #players td { 94 | padding-right: .5em; 95 | } 96 | 97 | #settings { 98 | float: right; 99 | text-align: right; 100 | } 101 | 102 | #settings table { 103 | margin-left: auto; 104 | } 105 | 106 | #settings table input { 107 | width: 3em; 108 | } 109 | 110 | #settings tr:first-of-type { 111 | text-align: left; 112 | } 113 | 114 | #settings img { 115 | height: 32px; 116 | width: 32px; 117 | } 118 | 119 | #chat { 120 | border-left: 1px solid black; 121 | position: fixed; 122 | top: 0; 123 | right: 0; 124 | bottom: 0; 125 | width: 350px; 126 | 127 | display: flex; 128 | flex-direction: column; 129 | } 130 | 131 | #chat[hidden] { 132 | /* flexbox breaks the hidden attribute? */ 133 | display: none; 134 | } 135 | 136 | #messages { 137 | overflow: auto; 138 | flex-grow: 1; 139 | } 140 | 141 | #chat input { 142 | flex-shrink: 0 143 | } 144 | -------------------------------------------------------------------------------- /run.js: -------------------------------------------------------------------------------- 1 | var spawn = require('child_process').spawn 2 | 3 | ;(function run() { 4 | spawn('node', ['app.js'], { stdio: 'inherit' }) 5 | .on('close', run) 6 | })() 7 | -------------------------------------------------------------------------------- /src/_.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ascii(s) { 3 | return s.replace(/[Æâàáéíöúû’]/g, c => { 4 | switch (c) { 5 | case 'Æ': return 'AE' 6 | case 'â': case 'à': case 'á': return 'a' 7 | case 'é': return 'e' 8 | case 'í': return 'i' 9 | case 'ö': return 'o' 10 | case 'ú': case 'û': return 'u' 11 | case '’': return "'" 12 | } 13 | }) 14 | }, 15 | at(arr, index) { 16 | var {length} = arr 17 | index = (index % length + length) % length//please kill me it hurts to live 18 | return arr[index] 19 | }, 20 | count(arr, attr) { 21 | var count = {} 22 | for (var item of arr) { 23 | var key = item[attr] 24 | count[key] || (count[key] = 0) 25 | count[key]++ 26 | } 27 | return count 28 | }, 29 | choose(n, arr) { 30 | // arr.slice(0) copies the entire array 31 | if (n === 0) 32 | return [] 33 | 34 | // http://en.wikipedia.org/wiki/Fisher–Yates_shuffle 35 | var i = arr.length 36 | var end = i - n 37 | while (i > end) { 38 | var j = this.rand(i--) 39 | ;[arr[i], arr[j]] = [arr[j], arr[i]] 40 | } 41 | return arr.slice(-n) 42 | }, 43 | shuffle(arr) { 44 | return this.choose(arr.length, arr) 45 | }, 46 | id() { 47 | return Math.random().toString(36).slice(2) 48 | }, 49 | rand(n) { 50 | return Math.floor(Math.random() * n) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/bot.js: -------------------------------------------------------------------------------- 1 | var {EventEmitter} = require('events') 2 | 3 | module.exports = class extends EventEmitter { 4 | constructor() { 5 | Object.assign(this, { 6 | isBot: true, 7 | name: 'bot', 8 | packs: [], 9 | time: 0 10 | }) 11 | } 12 | getPack(pack) { 13 | var score = 99 14 | var index = 0 15 | pack.forEach((card, i) => { 16 | if (card.score < score) { 17 | score = card.score 18 | index = i 19 | }}) 20 | pack.splice(index, 1) 21 | this.emit('pass', pack) 22 | } 23 | send(){} 24 | err(){} 25 | } 26 | -------------------------------------------------------------------------------- /src/data.js: -------------------------------------------------------------------------------- 1 | try { 2 | var Cards = require('../data/cards') 3 | var Sets = require('../data/sets') 4 | } catch(err) { 5 | Cards = {} 6 | Sets = {} 7 | } 8 | 9 | module.exports = { Cards, Sets, 10 | mws: require('../data/mws') 11 | } 12 | -------------------------------------------------------------------------------- /src/game.js: -------------------------------------------------------------------------------- 1 | var _ = require('./_') 2 | var Bot = require('./bot') 3 | var Human = require('./human') 4 | var Pool = require('./pool') 5 | var Room = require('./room') 6 | 7 | var SECOND = 1000 8 | var MINUTE = 1000 * 60 9 | var HOUR = 1000 * 60 * 60 10 | 11 | var games = {} 12 | 13 | ;(function playerTimer() { 14 | for (var id in games) { 15 | var game = games[id] 16 | if (game.round < 1) 17 | continue 18 | for (var p of game.players) 19 | if (p.time && !--p.time) 20 | p.pickRand() 21 | } 22 | setTimeout(playerTimer, SECOND) 23 | })() 24 | 25 | ;(function gameTimer() { 26 | var now = Date.now() 27 | for (var id in games) 28 | if (games[id].expires < now) 29 | games[id].kill('game over') 30 | 31 | setTimeout(gameTimer, MINUTE) 32 | })() 33 | 34 | module.exports = class Game extends Room { 35 | constructor({id, seats, type, sets, cube}) { 36 | super() 37 | 38 | if (sets) 39 | Object.assign(this, { sets, 40 | title: sets.join(' / ')}) 41 | else { 42 | var title = type 43 | if (type === 'cube draft') 44 | title += ' ' + cube.packs + 'x' + cube.cards 45 | Object.assign(this, { cube, title }) 46 | } 47 | 48 | var gameID = _.id() 49 | Object.assign(this, { seats, type, 50 | delta: -1, 51 | hostID: id, 52 | id: gameID, 53 | players: [], 54 | round: 0, 55 | rounds: cube ? cube.packs : 3 56 | }) 57 | this.renew() 58 | games[gameID] = this 59 | } 60 | 61 | renew() { 62 | this.expires = Date.now() + HOUR 63 | } 64 | 65 | name(name, sock) { 66 | super(name, sock) 67 | sock.h.name = sock.name 68 | this.meta() 69 | } 70 | 71 | join(sock) { 72 | for (var i = 0; i < this.players.length; i++) { 73 | var p = this.players[i] 74 | if (p.id === sock.id) { 75 | p.attach(sock) 76 | this.greet(p) 77 | this.meta() 78 | super(sock) 79 | return 80 | } 81 | } 82 | 83 | if (this.round) 84 | return sock.err('game started') 85 | 86 | super(sock) 87 | 88 | var h = new Human(sock) 89 | if (h.id === this.hostID) { 90 | h.isHost = true 91 | sock.once('start', this.start.bind(this)) 92 | sock.on('kick', this.kick.bind(this)) 93 | } 94 | h.on('meta', this.meta.bind(this)) 95 | this.players.push(h) 96 | this.greet(h) 97 | this.meta() 98 | } 99 | 100 | kick(i) { 101 | var h = this.players[i] 102 | if (!h || h.isBot) 103 | return 104 | 105 | if (this.round) 106 | h.kick() 107 | else 108 | h.exit() 109 | 110 | h.err('you were kicked') 111 | } 112 | 113 | greet(h) { 114 | h.send('set', { 115 | isHost: h.isHost, 116 | round: this.round, 117 | self: this.players.indexOf(h), 118 | title: this.title 119 | }) 120 | } 121 | 122 | exit(sock) { 123 | if (this.round) 124 | return 125 | 126 | sock.removeAllListeners('start') 127 | var index = this.players.indexOf(sock.h) 128 | this.players.splice(index, 1) 129 | 130 | this.players.forEach((p, i) => 131 | p.send('set', { self: i })) 132 | this.meta() 133 | } 134 | 135 | meta(state={}) { 136 | state.players = this.players.map(p => ({ 137 | hash: p.hash, 138 | name: p.name, 139 | time: p.time, 140 | packs: p.packs.length 141 | })) 142 | for (var p of this.players) 143 | p.send('set', state) 144 | } 145 | 146 | kill(msg) { 147 | if (this.round > -1) 148 | this.players.forEach(p => p.err(msg)) 149 | 150 | delete games[this.id] 151 | this.emit('kill') 152 | } 153 | 154 | end() { 155 | this.renew() 156 | this.round = -1 157 | this.meta({ round: -1 }) 158 | } 159 | 160 | pass(p, pack) { 161 | if (!pack.length) { 162 | if (!--this.packCount) 163 | this.startRound() 164 | else 165 | this.meta() 166 | return 167 | } 168 | 169 | var index = this.players.indexOf(p) + this.delta 170 | var p2 = _.at(this.players, index) 171 | p2.getPack(pack) 172 | if (!p2.isBot) 173 | this.meta() 174 | } 175 | 176 | startRound() { 177 | if (this.round++ === this.rounds) 178 | return this.end() 179 | 180 | var {players} = this 181 | this.packCount = players.length 182 | this.delta *= -1 183 | 184 | for (var p of players) 185 | if (!p.isBot) 186 | p.getPack(this.pool.pop()) 187 | 188 | //let the bots play 189 | this.meta = ()=>{} 190 | var index = players.findIndex(p => !p.isBot) 191 | var count = players.length 192 | while (--count) { 193 | index -= this.delta 194 | p = _.at(players, index) 195 | if (p.isBot) 196 | p.getPack(this.pool.pop()) 197 | } 198 | this.meta = Game.prototype.meta 199 | this.meta({ round: this.round }) 200 | } 201 | 202 | hash(h, deck) { 203 | h.hash = hash(deck) 204 | this.meta() 205 | } 206 | 207 | start([addBots, useTimer]) { 208 | var src = this.cube ? this.cube : this.sets 209 | var {players} = this 210 | var p 211 | 212 | this.renew() 213 | 214 | if (/sealed/.test(this.type)) { 215 | this.round = -1 216 | var pools = Pool(src, players.length, true) 217 | for (p of players) { 218 | p.pool = pools.pop() 219 | p.send('pool', p.pool) 220 | p.send('set', { round: -1 }) 221 | } 222 | return 223 | } 224 | 225 | for (p of players) 226 | p.useTimer = useTimer 227 | 228 | if (addBots) 229 | while (players.length < this.seats) 230 | players.push(new Bot) 231 | _.shuffle(players) 232 | 233 | this.pool = Pool(src, players.length) 234 | players.forEach((p, i) => { 235 | p.on('pass', this.pass.bind(this, p)) 236 | p.send('set', { self: i }) 237 | }) 238 | this.startRound() 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /src/hash.js: -------------------------------------------------------------------------------- 1 | var crypto = require('crypto') 2 | 3 | var opts = { 4 | cock: { 5 | algo: 'sha1', 6 | separator: ';', 7 | prefix: 'SB:', 8 | name(name) { 9 | return name.toLowerCase() 10 | }, 11 | digest(digest) { 12 | // 10 digits of base 16 -> 8 digits of base 32 13 | return parseInt(digest.slice(0, 10), 16).toString(32) 14 | } 15 | }, 16 | mws: { 17 | algo: 'md5', 18 | separator: '', 19 | prefix: '#', 20 | name(name) { 21 | return name.toUpperCase().replace(/[^A-Z]/g, '') 22 | }, 23 | digest(digest) { 24 | return digest.slice(0, 8) 25 | } 26 | } 27 | } 28 | 29 | function hash(deck, opts) { 30 | var items = [] 31 | for (var zoneName in deck) { 32 | var prefix = zoneName === 'side' 33 | ? opts.prefix 34 | : '' 35 | 36 | var cards = deck[zoneName] 37 | for (var cardName in cards) { 38 | var count = cards[cardName] 39 | var item = prefix + opts.name(cardName) 40 | while (count--) 41 | items.push(item) 42 | } 43 | } 44 | 45 | var data = items.sort().join(opts.separator) 46 | var digest = crypto 47 | .createHash(opts.algo) 48 | .update(data, 'ascii') 49 | .digest('hex') 50 | return opts.digest(digest) 51 | } 52 | 53 | module.exports = function (deck) { 54 | return { 55 | cock: hash(deck, opts.cock), 56 | mws: hash(deck, opts.mws) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/human.js: -------------------------------------------------------------------------------- 1 | var {EventEmitter} = require('events') 2 | var _ = require('./_') 3 | var util = require('./util') 4 | var hash = require('./hash') 5 | 6 | module.exports = class extends EventEmitter { 7 | constructor(sock) { 8 | Object.assign(this, { 9 | id: sock.id, 10 | name: sock.name, 11 | time: 0, 12 | packs: [], 13 | pool: [] 14 | }) 15 | this.attach(sock) 16 | } 17 | attach(sock) { 18 | if (this.sock && this.sock !== sock) 19 | this.sock.ws.close() 20 | 21 | sock.mixin(this) 22 | sock.on('pick', this._pick.bind(this)) 23 | sock.on('hash', this._hash.bind(this)) 24 | 25 | var [pack] = this.packs 26 | if (pack) 27 | this.send('pack', pack) 28 | this.send('pool', this.pool) 29 | } 30 | _hash(deck) { 31 | if (!util.deck(deck, this.pool)) 32 | return 33 | 34 | this.hash = hash(deck) 35 | this.emit('meta') 36 | } 37 | _pick(index) { 38 | var [pack] = this.packs 39 | if (pack && index < pack.length) 40 | this.pick(index) 41 | } 42 | getPack(pack) { 43 | if (this.packs.push(pack) === 1) 44 | this.sendPack(pack) 45 | } 46 | sendPack(pack) { 47 | if (pack.length === 1) 48 | return this.pick(0) 49 | 50 | if (this.useTimer) 51 | this.time = 20 + 5 * pack.length 52 | 53 | this.send('pack', pack) 54 | } 55 | pick(index) { 56 | var pack = this.packs.shift() 57 | var card = pack.splice(index, 1)[0] 58 | 59 | this.pool.push(card) 60 | this.send('add', card.name) 61 | 62 | var [next] = this.packs 63 | if (!next) 64 | this.time = 0 65 | else 66 | this.sendPack(next) 67 | 68 | this.emit('pass', pack) 69 | } 70 | pickRand() { 71 | var index = _.rand(this.packs[0].length) 72 | this.pick(index) 73 | } 74 | kick() { 75 | this.send = ()=>{} 76 | while(this.packs.length) 77 | this.pickRand() 78 | this.sendPack = this.pickRand 79 | this.isBot = true 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/make/cards.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs') 2 | var _ = require('../_') 3 | var raw = require('../../data/AllSets') 4 | 5 | var COLORS = { 6 | W: 'White', 7 | U: 'Blue', 8 | B: 'Black', 9 | R: 'Red', 10 | G: 'Green' 11 | } 12 | 13 | var Cards = {} 14 | var Sets = {} 15 | 16 | before() 17 | 18 | var types = ['core', 'expansion', 'commander', 'planechase', 'starter', 'un'] 19 | var codes = ['MMA', 'VMA', 'CNS', 'TPR', 'MM2'] 20 | for (var code in raw) { 21 | var set = raw[code] 22 | if (types.indexOf(set.type) > -1 23 | || codes.indexOf(code) > -1) 24 | doSet(set, code) 25 | } 26 | 27 | after() 28 | 29 | fs.writeFileSync('data/cards.json', JSON.stringify(Cards, null, 2)) 30 | fs.writeFileSync('data/sets.json', JSON.stringify(Sets, null, 2)) 31 | 32 | function before() { 33 | raw.UGL.cards = raw.UGL.cards.filter(x => x.layout !== 'token') 34 | 35 | raw.TSP.cards = raw.TSP.cards.concat(raw.TSB.cards) 36 | delete raw.TSB 37 | 38 | raw.PLC.booster = Array(11).fill('common') 39 | raw.FUT.booster = Array(11).fill('common') 40 | 41 | ;['BFZ', 'OGW'].forEach(setName => { 42 | for (card of raw[setName].cards) 43 | if (card.text && card.text.startsWith('Devoid')) 44 | card.colors = card.manaCost 45 | .replace(/[\d{}]/g, '') 46 | .replace(/(.)\1+/g, '$1') 47 | .split('') 48 | .map(c => COLORS[c]) 49 | }) 50 | 51 | var card 52 | for (card of raw.ISD.cards) 53 | if (card.layout === 'double-faced') 54 | card.rarity = 'special' 55 | 56 | for (card of raw.DGM.cards) 57 | if (/Guildgate/.test(card.name)) 58 | card.rarity = 'special' 59 | 60 | for (card of raw.CNS.cards) 61 | if ((card.type === 'Conspiracy') 62 | || /draft/.test(card.text)) 63 | card.rarity = 'special' 64 | 65 | for (card of raw.FRF.cards) 66 | if (card.types[0] === 'Land' 67 | && (card.name !== 'Crucible of the Spirit Dragon')) 68 | card.rarity = 'special' 69 | 70 | //http://mtgsalvation.gamepedia.com/Magic_2015/Sample_decks 71 | // Each sample deck has several cards numbered 270 and higher that do not 72 | // appear in Magic 2015 booster packs. 73 | raw.M15.cards = raw.M15.cards.filter(x => parseInt(x.number) < 270) 74 | 75 | raw.OGW.cards.find(x => x.name === 'Wastes').rarity = 'Common' 76 | } 77 | 78 | function after() { 79 | var {ISD} = Sets 80 | ISD.special = { 81 | mythic: [ 82 | 'garruk relentless' 83 | ], 84 | rare: [ 85 | 'bloodline keeper', 86 | 'daybreak ranger', 87 | 'instigator gang', 88 | 'kruin outlaw', 89 | 'ludevic\'s test subject', 90 | 'mayor of avabruck' 91 | ], 92 | uncommon: [ 93 | 'civilized scholar', 94 | 'cloistered youth', 95 | 'gatstaf shepherd', 96 | 'hanweir watchkeep', 97 | 'reckless waif', 98 | 'screeching bat', 99 | 'ulvenwald mystics' 100 | ], 101 | common: [ 102 | 'delver of secrets', 103 | 'grizzled outcasts', 104 | 'thraben sentry', 105 | 'tormented pariah', 106 | 'village ironsmith', 107 | 'villagers of estwald' 108 | ] 109 | } 110 | var {DKA} = Sets 111 | DKA.special = { 112 | mythic: [ 113 | 'elbrus, the binding blade', 114 | 'huntmaster of the fells' 115 | ], 116 | rare: [ 117 | 'mondronen shaman', 118 | 'ravenous demon' 119 | ], 120 | uncommon: [ 121 | 'afflicted deserter', 122 | 'chalice of life', 123 | 'lambholt elder', 124 | 'soul seizer' 125 | ], 126 | common: [ 127 | 'chosen of markov', 128 | 'hinterland hermit', 129 | 'loyal cathar', 130 | 'scorned villager' 131 | ] 132 | } 133 | var {DGM} = Sets 134 | DGM.mythic.splice(DGM.mythic.indexOf("maze's end"), 1) 135 | DGM.special = { 136 | gate: DGM.special, 137 | shock: [ 138 | 'blood crypt', 139 | 'breeding pool', 140 | 'godless shrine', 141 | 'hallowed fountain', 142 | 'overgrown tomb', 143 | 'sacred foundry', 144 | 'steam vents', 145 | 'stomping ground', 146 | 'temple garden', 147 | 'watery grave', 148 | 'maze\'s end' 149 | ] 150 | } 151 | alias(DGM.special.shock, 'DGM') 152 | 153 | var {FRF} = Sets 154 | for (let card of FRF.special) 155 | Cards[card].sets.FRF.rarity = / /.test(card) ? 'common' : 'basic' 156 | FRF.special = { 157 | common: FRF.special, 158 | fetch: [ 159 | 'flooded strand', 160 | 'bloodstained mire', 161 | 'wooded foothills', 162 | 'windswept heath', 163 | 'polluted delta', 164 | ] 165 | } 166 | alias(FRF.special.fetch, 'FRF') 167 | 168 | Sets.OGW.common.push('wastes')// wastes are twice as common 169 | } 170 | 171 | function alias(arr, code) { 172 | // some boosters contain reprints which are not in the set proper 173 | for (var cardName of arr) { 174 | var {sets} = Cards[cardName] 175 | var codes = Object.keys(sets) 176 | var last = codes[codes.length - 1] 177 | sets[code] = sets[last] 178 | } 179 | } 180 | 181 | function doSet(rawSet, code) { 182 | var cards = {} 183 | var set = { 184 | common: [], 185 | uncommon: [], 186 | rare: [], 187 | mythic: [], 188 | special: [], 189 | } 190 | var card 191 | 192 | for (card of rawSet.cards) 193 | doCard(card, cards, code, set) 194 | 195 | //because of split cards, do this only after processing the entire set 196 | for (var cardName in cards) { 197 | card = cards[cardName] 198 | var lc = cardName.toLowerCase() 199 | 200 | if (lc in Cards) 201 | Cards[lc].sets[code] = card.sets[code] 202 | else 203 | Cards[lc] = card 204 | } 205 | 206 | if (!rawSet.booster) 207 | return 208 | 209 | for (var rarity of ['mythic', 'special']) 210 | if (!set[rarity].length) 211 | delete set[rarity] 212 | 213 | set.size = rawSet.booster.filter(x => x === 'common').length 214 | Sets[code] = set 215 | } 216 | 217 | function doCard(rawCard, cards, code, set) { 218 | var rarity = rawCard.rarity.split(' ')[0].toLowerCase() 219 | if (rarity === 'basic') 220 | return 221 | 222 | var {name} = rawCard 223 | if (['double-faced', 'flip'].indexOf(rawCard.layout) > -1 224 | && rawCard.number.indexOf('b') > -1) 225 | return 226 | 227 | if (rawCard.layout === 'split') 228 | name = rawCard.names.join(' // ') 229 | 230 | name = _.ascii(name) 231 | 232 | if (name in cards) { 233 | if (rawCard.layout === 'split') { 234 | var card = cards[name] 235 | card.cmc += rawCard.cmc 236 | if (card.color !== rawCard.color) 237 | card.color = 'multicolor' 238 | } 239 | return 240 | } 241 | 242 | var {colors} = rawCard 243 | var color = !colors ? 'colorless' : 244 | colors.length > 1 ? 'multicolor' : 245 | colors[0].toLowerCase() 246 | 247 | cards[name] = { color, name, 248 | type: rawCard.types[rawCard.types.length - 1], 249 | cmc: rawCard.cmc || 0, 250 | sets: { 251 | [code]: { rarity, 252 | url: `http://gatherer.wizards.com/Handlers/Image.ashx?multiverseid=${rawCard.multiverseid}&type=card` 253 | } 254 | } 255 | } 256 | 257 | set[rarity].push(name.toLowerCase()) 258 | } 259 | -------------------------------------------------------------------------------- /src/make/custom.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | 3 | const Cards = require('../../data/cards') 4 | const Sets = require('../../data/sets') 5 | const {code, cards, size} = require('../../data/custom') 6 | 7 | if (Sets[code]) { 8 | console.log('already processed, exiting') 9 | process.exit() 10 | } 11 | 12 | const COLORS = { 13 | W: 'white', 14 | U: 'blue', 15 | B: 'black', 16 | R: 'red', 17 | G: 'green' 18 | } 19 | 20 | const set = Sets[code] = { 21 | common: [], 22 | uncommon: [], 23 | rare: [], 24 | mythic: [], 25 | size: size || 10 26 | } 27 | 28 | cards.forEach(rawCard => { 29 | const rarity = rawCard.rarity.split(' ')[0].toLowerCase() 30 | if (rarity === 'basic') 31 | return 32 | 33 | const {name} = rawCard 34 | const lc = name.toLowerCase() 35 | set[rarity].push(lc) 36 | 37 | const sets = {[code]: { rarity, url: rawCard.url }} 38 | if (Cards[lc]) 39 | return Cards[lc].sets[code] = sets[code] 40 | 41 | const {cid} = rawCard 42 | const color 43 | = cid.length === 1 ? COLORS[cid[0]] 44 | : !cid.length ? 'colorless' 45 | : 'multicolor' 46 | 47 | Cards[lc] = { 48 | cmc: rawCard.cmc, 49 | color, 50 | name, 51 | type: rawCard.type.split(' ')[0], 52 | sets 53 | } 54 | }) 55 | 56 | fs.writeFileSync('data/cards.json', JSON.stringify(Cards, null, 2)) 57 | fs.writeFileSync('data/sets.json', JSON.stringify(Sets, null, 2)) 58 | -------------------------------------------------------------------------------- /src/make/index.js: -------------------------------------------------------------------------------- 1 | var target = process.argv[2] 2 | require('traceur').require.makeDefault(function(path) { 3 | return path.indexOf('node_modules') === -1 4 | }) 5 | require('./' + target) 6 | -------------------------------------------------------------------------------- /src/make/score.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs') 2 | var fetch = require('node-fetch') 3 | var {Cards} = require('../data') 4 | 5 | var URL = 'https://aeos.cloudant.com/draft/_design/draft/_view/score?group=true' 6 | 7 | fetch(URL) 8 | .then(res => { 9 | if (res.ok) 10 | return res.json() 11 | throw Error('not ok') 12 | }) 13 | .then(data => { 14 | for (var row of data.rows) { 15 | var {key, value} = row 16 | var lc = key.toLowerCase() 17 | // TODO scrub the db 18 | if (!(lc in Cards)) 19 | continue 20 | 21 | Cards[lc].score = value.sum / value.count 22 | } 23 | 24 | fs.writeFileSync('data/cards.json', JSON.stringify(Cards, null, 2)) 25 | }) 26 | .catch(console.log) 27 | -------------------------------------------------------------------------------- /src/pool.js: -------------------------------------------------------------------------------- 1 | var _ = require('./_') 2 | var {Cards, Sets, mws} = require('./data') 3 | 4 | function selectRarity(set) { 5 | // average pack contains: 6 | // 14 cards 7 | // 10 commons 8 | // 3 uncommons 9 | // 7/8 rare 10 | // 1/8 mythic 11 | // * 8 -> 112/80/24/7/1 12 | 13 | let n = _.rand(112) 14 | if (n < 1) 15 | return set.mythic 16 | if (n < 8) 17 | return set.rare 18 | if (n < 32) 19 | return set.uncommon 20 | return set.common 21 | } 22 | 23 | function toPack(code) { 24 | var set = Sets[code] 25 | var {common, uncommon, rare, mythic, special, size} = set 26 | if (mythic && !_.rand(8)) 27 | rare = mythic 28 | 29 | var pack = [].concat( 30 | _.choose(size, common), 31 | _.choose(3, uncommon), 32 | _.choose(1, rare) 33 | ) 34 | 35 | switch (code) { 36 | case 'DGM': 37 | special = _.rand(20) 38 | ? special.gate 39 | : special.shock 40 | break 41 | case 'MMA': 42 | special = selectRarity(set) 43 | break 44 | case 'MM2': 45 | special = selectRarity(set) 46 | break 47 | case 'VMA': 48 | //http://www.wizards.com/magic/magazine/article.aspx?x=mtg/daily/arcana/1491 49 | if (_.rand(53)) 50 | special = selectRarity(set) 51 | break 52 | case 'FRF': 53 | special = _.rand(20) 54 | ? special.common 55 | : special.fetch 56 | break 57 | case 'ISD': 58 | //http://www.mtgsalvation.com/forums/magic-fundamentals/magic-general/327956-innistrad-block-transforming-card-pack-odds?comment=4 59 | //121 card sheet, 1 mythic, 12 rare (13), 42 uncommon (55), 66 common 60 | let specialrnd = _.rand(121) 61 | if (specialrnd == 0) 62 | special = special.mythic 63 | else if (specialrnd < 13) 64 | special = special.rare 65 | else if (specialrnd < 55) 66 | special = special.uncommon 67 | else 68 | special = special.common 69 | break 70 | case 'DKA': 71 | //http://www.mtgsalvation.com/forums/magic-fundamentals/magic-general/327956-innistrad-block-transforming-card-pack-odds?comment=4 72 | //80 card sheet, 2 mythic, 6 rare (8), 24 uncommon (32), 48 common 73 | let specialrnd = _.rand(80) 74 | if (specialrnd <= 1) 75 | special = special.mythic 76 | else if (specialrnd < 8) 77 | special = special.rare 78 | else if (specialrnd < 32) 79 | special = special.uncommon 80 | else 81 | special = special.common 82 | break 83 | } 84 | 85 | if (special) 86 | pack.push(_.choose(1, special)) 87 | 88 | return toCards(pack, code) 89 | } 90 | 91 | function toCards(pool, code) { 92 | var isCube = !code 93 | return pool.map(cardName => { 94 | var card = Object.assign({}, Cards[cardName]) 95 | 96 | var {sets} = card 97 | if (isCube) 98 | [code] = Object.keys(sets) 99 | card.code = mws[code] || code 100 | 101 | var set = sets[code] 102 | delete card.sets 103 | return Object.assign(card, set) 104 | }) 105 | } 106 | 107 | module.exports = function (src, playerCount, isSealed) { 108 | if (!(src instanceof Array)) { 109 | var isCube = true 110 | _.shuffle(src.list) 111 | } 112 | if (isSealed) { 113 | var count = playerCount 114 | var size = 90 115 | } else { 116 | count = playerCount * src.packs 117 | size = src.cards 118 | } 119 | var pools = [] 120 | 121 | if (isCube || isSealed) 122 | while (count--) 123 | pools.push(isCube 124 | ? toCards(src.list.splice(-size)) 125 | : [].concat(...src.map(toPack))) 126 | else 127 | for (var code of src.reverse()) 128 | for (var i = 0; i < playerCount; i++) 129 | pools.push(toPack(code)) 130 | 131 | return pools 132 | } 133 | -------------------------------------------------------------------------------- /src/room.js: -------------------------------------------------------------------------------- 1 | var {EventEmitter} = require('events') 2 | 3 | module.exports = class extends EventEmitter { 4 | constructor() { 5 | this.messages = Array(15) 6 | this.socks = [] 7 | } 8 | join(sock) { 9 | this.socks.push(sock) 10 | sock.once('exit', this.exit.bind(this)) 11 | sock.on('say', this.say.bind(this)) 12 | sock.on('name', this.name.bind(this)) 13 | sock.send('chat', this.messages) 14 | } 15 | name(name, sock) { 16 | if (typeof name !== 'string') 17 | return 18 | sock.name = name.slice(0, 15) 19 | } 20 | exit(sock) { 21 | sock.removeAllListeners('say') 22 | var index = this.socks.indexOf(sock) 23 | this.socks.splice(index, 1) 24 | } 25 | say(text, sock) { 26 | var msg = { text, 27 | time: Date.now(), 28 | name: sock.name 29 | } 30 | 31 | this.messages.shift() 32 | this.messages.push(msg) 33 | for (sock of this.socks) 34 | sock.send('hear', msg) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/router.js: -------------------------------------------------------------------------------- 1 | var Game = require('./game') 2 | var Room = require('./room') 3 | var Sock = require('./sock') 4 | var util = require('./util') 5 | 6 | var rooms = { 7 | lobby: new Room 8 | } 9 | 10 | function create(opts) { 11 | try { 12 | util.game(opts) 13 | } catch(err) { 14 | return this.err(err.message) 15 | } 16 | 17 | opts.id = this.id 18 | var g = new Game(opts) 19 | rooms[g.id] = g 20 | this.send('route', 'g/' + g.id) 21 | g.once('kill', kill) 22 | } 23 | 24 | function join(roomID) { 25 | var room = rooms[roomID] 26 | if (!room) 27 | return this.err(`room ${roomID} not found`) 28 | this.exit() 29 | room.join(this) 30 | } 31 | 32 | function kill() { 33 | delete rooms[this.id] 34 | } 35 | 36 | module.exports = function (ws) { 37 | var sock = new Sock(ws) 38 | sock.on('join', join) 39 | sock.on('create', create) 40 | } 41 | -------------------------------------------------------------------------------- /src/sock.js: -------------------------------------------------------------------------------- 1 | var {EventEmitter} = require('events') 2 | 3 | function message(msg) { 4 | var [type, data] = JSON.parse(msg) 5 | this.emit(type, data, this) 6 | } 7 | 8 | var mixins = { 9 | err(msg) { 10 | this.send('error', msg) 11 | }, 12 | send(type, data) { 13 | this.ws.send(JSON.stringify([type, data])) 14 | }, 15 | exit() { 16 | this.emit('exit', this) 17 | } 18 | } 19 | 20 | module.exports = class extends EventEmitter { 21 | constructor(ws) { 22 | this.ws = ws 23 | var {id='', name='newfriend'} = ws.request._query 24 | this.id = id.slice(0, 25) 25 | this.name = name.slice(0, 15) 26 | 27 | for (var key in mixins) 28 | this[key] = mixins[key].bind(this) 29 | 30 | ws.on('message', message.bind(this)) 31 | ws.on('close', this.exit) 32 | } 33 | mixin(h) { 34 | h.sock = this 35 | this.h = h 36 | for (var key in mixins) 37 | h[key] = this[key] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert') 2 | var _ = require('./_') 3 | var {Cards, Sets} = require('./data') 4 | var BASICS = [ 5 | 'Forest', 6 | 'Island', 7 | 'Mountain', 8 | 'Plains', 9 | 'Swamp' 10 | ] 11 | 12 | function transform(cube, seats, type) { 13 | var {list, cards, packs} = cube 14 | 15 | assert(typeof list === 'string', 'typeof list') 16 | assert(typeof cards === 'number', 'typeof cards') 17 | assert(8 <= cards && cards <= 15, 'cards range') 18 | assert(typeof packs === 'number', 'typeof packs') 19 | assert(3 <= packs && packs <= 7, 'packs range') 20 | 21 | list = list.split('\n').map(_.ascii) 22 | 23 | var min = type === 'cube draft' 24 | ? seats * cards * packs 25 | : seats * 90 26 | assert(min <= list.length && list.length <= 1e3, 27 | `this cube needs between ${min} and 1000 cards; it has ${list.length}`) 28 | 29 | var bad = [] 30 | for (var cardName of list) 31 | if (!(cardName in Cards)) 32 | bad.push(cardName) 33 | 34 | if (bad.length) { 35 | var msg = `invalid cards: ${bad.splice(-10).join('; ')}` 36 | if (bad.length) 37 | msg += `; and ${bad.length} more` 38 | throw Error(msg) 39 | } 40 | 41 | cube.list = list 42 | } 43 | 44 | var util = module.exports = { 45 | deck(deck, pool) { 46 | pool = _.count(pool, 'name') 47 | 48 | for (var zoneName in deck) { 49 | var zone = deck[zoneName] 50 | for (var cardName in zone) { 51 | if (typeof zone[cardName] !== 'number') 52 | return 53 | if (BASICS.indexOf(cardName) > -1) 54 | continue 55 | if (!(cardName in pool)) 56 | return 57 | pool[cardName] -= zone[cardName] 58 | if (pool[cardName] < 0) 59 | return 60 | } 61 | } 62 | 63 | return true 64 | }, 65 | game({seats, type, sets, cube}) { 66 | assert(typeof seats === 'number', 'typeof seats') 67 | assert(2 <= seats && seats <= 8, 'seats range') 68 | assert(['draft', 'sealed', 'cube draft', 'cube sealed'].indexOf(type) > -1, 69 | 'indexOf type') 70 | 71 | if (/cube/.test(type)) 72 | transform(cube, seats, type) 73 | else 74 | sets.forEach(set => assert(set in Sets, `${set} in Sets`)) 75 | } 76 | } 77 | --------------------------------------------------------------------------------